-
-
-
- Loading profile...
-
-
- Public Profile
-
-
-
-
-
-
-
-
-
-
- For your security, choose a location nearby but not exactly at your
- place.
-
-
-
{
- userProfileLatitude = event.latlng.lat;
- userProfileLongitude = event.latlng.lng;
- }
- "
- @ready="onMapReady"
- >
-
-
-
-
-
-
-
-
-
-
-
Loading...
-
Saving...
-
+ :active-did="activeDid"
+ :partner-api-server="partnerApiServer"
+ @profile-updated="handleProfileUpdate"
+ />
();
+// Update the error type definitions
+interface ErrorDetail {
+ message?: string;
+ [key: string]: unknown;
+}
+
+interface ApiErrorResponse {
+ error: string | ErrorDetail;
+ [key: string]: unknown;
+}
+
+interface ApiError {
+ status?: number;
+ response?: {
+ data?: ApiErrorResponse;
+ };
+ message?: string;
+}
+
+interface AxiosErrorDetail {
+ status: number;
+ response?: {
+ data?: ApiErrorResponse;
+ };
+ message?: string;
+}
+
@Component({
components: {
EntityIcon,
ImageMethodDialog,
- LeafletMouseEvent,
LMap,
LMarker,
LTileLayer,
@@ -999,6 +930,7 @@ const inputImportFileNameRef = ref();
QuickNav,
TopMessage,
UserNameDialog,
+ ProfileSection,
},
})
export default class AccountViewView extends Vue {
@@ -1091,11 +1023,11 @@ export default class AccountViewView extends Vue {
this.includeUserProfileLocation = true;
}
} else {
- // won't get here because axios throws an error instead
- throw Error("Unable to load profile.");
+ throw new Error("Unable to load profile.");
}
- } catch (error) {
- if (error.status === 404) {
+ } catch (error: unknown) {
+ const typedError = error as { status?: number };
+ if (typedError.status === 404) {
// this is ok: the profile is not yet created
} else {
logConsoleAndDb(
@@ -1259,7 +1191,7 @@ export default class AccountViewView extends Vue {
});
}
- readableDate(timeStr: string) {
+ readableDate(timeStr?: string): string {
return timeStr ? timeStr.substring(0, timeStr.indexOf("T")) : "?";
}
@@ -1440,109 +1372,75 @@ export default class AccountViewView extends Vue {
}
/**
- * Asynchronously exports the database into a downloadable JSON file.
+ * Exports the database to a JSON file, handling platform-specific requirements.
*
- * @throws Will notify the user if there is an export error.
- */
- public async exportDatabase() {
- try {
- // Generate the blob from the database
- const blob = await this.generateDatabaseBlob();
-
- // Create a temporary URL for the blob
- this.downloadUrl = this.createBlobURL(blob);
-
- // Trigger the download
- this.downloadDatabaseBackup(this.downloadUrl);
-
- // Notify the user that the download has started
- this.notifyDownloadStarted();
-
- // Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
- setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
- } catch (error) {
- this.handleExportError(error);
- }
- }
-
- /**
- * Generates a blob object representing the database.
+ * @internal
+ * @callGraph
+ * - Called by: template click handler
+ * - Calls: Filesystem.writeFile(), Share.share(), URL.createObjectURL()
*
- * @returns {Promise} The generated blob object.
- */
- private async generateDatabaseBlob(): Promise {
- return await db.export({ prettyJson: true });
- }
-
- /**
- * Creates a temporary URL for a blob object.
+ * @chain
+ * 1. Generate database blob
+ * 2. Convert to base64 for mobile platforms
+ * 3. Handle platform-specific export:
+ * - Mobile: Use Filesystem API and Share API
+ * - Web: Use URL.createObjectURL and download link
+ * - Electron: Use dialog and fs
*
- * @param {Blob} blob - The blob object.
- * @returns {string} The temporary URL for the blob.
- */
- private createBlobURL(blob: Blob): string {
- return URL.createObjectURL(blob);
- }
-
- /**
- * Triggers the download of the database backup.
+ * @requires
+ * - db: Dexie database instance
+ * - Filesystem API (for mobile)
+ * - Share API (for mobile)
+ * - fs module (for Electron)
+ * - dialog module (for Electron)
*
- * @param {string} url - The temporary URL for the blob.
+ * @modifies
+ * - downloadLinkRef: Sets href and triggers download
+ * - State: Updates UI feedback
*/
- private downloadDatabaseBackup(url: string) {
- const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
- downloadAnchor.href = url;
- downloadAnchor.download = `${db.name}-backup.json`;
- downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo
- }
-
- public computedStartDownloadLinkClassNames() {
- return {
- hidden: this.downloadUrl,
- };
- }
+ async exportDatabase() {
+ try {
+ // Generate database blob
+ const blob = await (Dexie as any).export(db, {
+ prettyJson: true,
+ });
- public computedDownloadLinkClassNames() {
- return {
- hidden: !this.downloadUrl,
- };
- }
+ // Convert blob to base64 for mobile platforms
+ const arrayBuffer = await blob.arrayBuffer();
+ const base64Data = Buffer.from(arrayBuffer).toString("base64");
- /**
- * Notifies the user that the download has started.
- */
- private notifyDownloadStarted() {
- this.$notify(
- {
- group: "alert",
- type: "success",
- title: "Download Started",
- text: "See your downloads directory for the backup. It is in the Dexie format.",
- },
- -1,
- );
- }
+ await DatabaseBackupService.createAndShareBackup(
+ base64Data,
+ arrayBuffer,
+ blob,
+ );
- /**
- * Handles errors during the database export process.
- *
- * @param {Error} error - The error object.
- */
- private handleExportError(error: unknown) {
- logger.error("Export Error:", error);
- this.$notify(
- {
- group: "alert",
- type: "danger",
- title: "Export Error",
- text: "There was an error exporting the data.",
- },
- 3000,
- );
+ this.$notify(
+ {
+ group: "alert",
+ type: "success",
+ title: "Export Complete",
+ text: "Your database has been exported successfully.",
+ },
+ 5000,
+ );
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ const errorMessage = error.message;
+ this.limitsMessage = errorMessage || "Bad server response.";
+ logger.error("Got bad response retrieving limits:", error);
+ } else {
+ this.limitsMessage = "Got an error retrieving limits.";
+ logger.error("Got some error retrieving limits:", error);
+ }
+ }
}
async uploadImportFile(event: Event) {
- inputImportFileNameRef.value = (event.target as EventTarget).files[0];
+ const target = event.target as HTMLInputElement;
+ if (target.files) {
+ inputImportFileNameRef.value = target.files[0];
+ }
}
showContactImport() {
@@ -1614,17 +1512,16 @@ export default class AccountViewView extends Vue {
reader.readAsText(inputImportFileNameRef.value as Blob);
}
- private progressCallback(progress: ImportProgress) {
+ private progressCallback(progress: DexieExportProgress) {
logger.log(
- `Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
+ `Export progress: ${progress.completedTables} of ${progress.totalTables} tables completed.`,
);
if (progress.done) {
- // console.log(`Imported ${progress.completedTables} tables.`);
this.$notify(
{
group: "alert",
type: "success",
- title: "Import Complete",
+ title: "Export Complete",
text: "",
},
5000,
@@ -1654,47 +1551,17 @@ export default class AccountViewView extends Vue {
this.limitsMessage = "";
try {
- const resp = await fetchEndorserRateLimits(
+ const response = await RateLimitsService.fetchRateLimits(
this.apiServer,
- this.axios,
did,
);
- if (resp.status === 200) {
- this.endorserLimits = resp.data;
- if (!this.isRegistered) {
- // the user was not known to be registered, but now they are (because we got no error) so let's record it
- try {
- await updateAccountSettings(did, { isRegistered: true });
- this.isRegistered = true;
- } catch (err) {
- logger.error("Got an error updating settings:", err);
- this.$notify(
- {
- group: "alert",
- type: "danger",
- title: "Update Error",
- text: "Unable to update your settings. Check claim limits again.",
- },
- 5000,
- );
- }
- }
- try {
- const imageResp = await fetchImageRateLimits(this.axios, did);
- if (imageResp.status === 200) {
- this.imageLimits = imageResp.data;
- } else {
- this.limitsMessage = "You don't have access to upload images.";
- }
- } catch {
- this.limitsMessage = "You cannot upload images.";
- }
- }
- } catch (error) {
- this.handleRateLimitsError(error);
+ this.endorserLimits = response;
+ } catch (error: unknown) {
+ this.limitsMessage = RateLimitsService.formatRateLimitError(error);
+ logger.error("Error fetching rate limits:", error);
+ } finally {
+ this.loadingLimits = false;
}
-
- this.loadingLimits = false;
}
/**
@@ -1704,25 +1571,61 @@ export default class AccountViewView extends Vue {
*/
private handleRateLimitsError(error: unknown) {
if (error instanceof AxiosError) {
- if (error.status == 400 || error.status == 404) {
- // no worries: they probably just aren't registered and don't have any limits
+ const axiosError = error as AxiosErrorDetail;
+ if (axiosError.status === 400 || axiosError.status === 404) {
logger.log(
"Got 400 or 404 response retrieving limits which probably means they're not registered:",
error,
);
this.limitsMessage = "No limits were found, so no actions are allowed.";
} else {
- const data = error.response?.data as ErrorResponse;
- this.limitsMessage =
- (data?.error?.message as string) || "Bad server response.";
+ const data = axiosError.response?.data as ApiErrorResponse;
+ const errorMessage =
+ typeof data?.error === "string"
+ ? data.error
+ : (data?.error as ErrorDetail)?.message || "Bad server response.";
+ this.limitsMessage = errorMessage;
logger.error("Got bad response retrieving limits:", error);
}
+ } else if (this.isApiError(error)) {
+ this.limitsMessage = this.getErrorMessage(error);
+ logger.error("Got API error retrieving limits:", error);
} else {
this.limitsMessage = "Got an error retrieving limits.";
logger.error("Got some error retrieving limits:", error);
}
}
+ private isApiError(error: unknown): error is ApiError {
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ "status" in error &&
+ "response" in error
+ );
+ }
+
+ private getErrorMessage(error: ApiError): string {
+ if (error.response?.data) {
+ const data = error.response.data;
+ if (typeof data.error === "string") {
+ return data.error;
+ }
+ return (data.error as ErrorDetail)?.message || "Bad server response.";
+ }
+ return error.message || "An unexpected error occurred";
+ }
+
+ private handleError(error: unknown): string {
+ if (this.isApiError(error)) {
+ return this.getErrorMessage(error);
+ }
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return "An unknown error occurred";
+ }
+
async onClickSaveApiServer() {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
@@ -1877,50 +1780,28 @@ export default class AccountViewView extends Vue {
async saveProfile() {
this.savingProfile = true;
try {
- const headers = await getHeaders(this.activeDid);
- const payload: UserProfile = {
+ await ProfileService.saveProfile(this.activeDid, this.partnerApiServer, {
description: this.userProfileDesc,
- };
- if (this.userProfileLatitude && this.userProfileLongitude) {
- payload.locLat = this.userProfileLatitude;
- payload.locLon = this.userProfileLongitude;
- } else if (this.includeUserProfileLocation) {
- this.$notify(
- {
- group: "alert",
- type: "toast",
- title: "",
- text: "No profile location is saved.",
- },
- 3000,
- );
- }
- const response = await this.axios.post(
- this.partnerApiServer + "/api/partner/userProfile",
- payload,
- { headers },
+ location: this.includeUserProfileLocation
+ ? {
+ lat: this.userProfileLatitude,
+ lng: this.userProfileLongitude,
+ }
+ : undefined,
+ });
+
+ this.$notify(
+ {
+ group: "alert",
+ type: "success",
+ title: "Profile Saved",
+ text: "Your profile has been updated successfully.",
+ },
+ 3000,
);
- if (response.status === 201) {
- this.$notify(
- {
- group: "alert",
- type: "success",
- title: "Profile Saved",
- text: "Your profile has been updated successfully.",
- },
- 3000,
- );
- } else {
- // won't get here because axios throws an error on non-success
- throw Error("Profile not saved");
- }
- } catch (error) {
+ } catch (error: unknown) {
+ const errorMessage = this.handleAxiosError(error);
logConsoleAndDb("Error saving profile: " + errorStringForLog(error));
- const errorMessage: string =
- error.response?.data?.error?.message ||
- error.response?.data?.error ||
- error.message ||
- "There was an error saving your profile.";
this.$notify(
{
group: "alert",
@@ -1982,35 +1863,23 @@ export default class AccountViewView extends Vue {
async deleteProfile() {
this.savingProfile = true;
try {
- const headers = await getHeaders(this.activeDid);
- const response = await this.axios.delete(
- this.partnerApiServer + "/api/partner/userProfile",
- { headers },
+ await ProfileService.deleteProfile(this.activeDid, this.partnerApiServer);
+ this.userProfileDesc = "";
+ this.userProfileLatitude = 0;
+ this.userProfileLongitude = 0;
+ this.includeUserProfileLocation = false;
+ this.$notify(
+ {
+ group: "alert",
+ type: "success",
+ title: "Profile Deleted",
+ text: "Your profile has been deleted successfully.",
+ },
+ 3000,
);
- if (response.status === 204) {
- this.userProfileDesc = "";
- this.userProfileLatitude = 0;
- this.userProfileLongitude = 0;
- this.includeUserProfileLocation = false;
- this.$notify(
- {
- group: "alert",
- type: "success",
- title: "Profile Deleted",
- text: "Your profile has been deleted successfully.",
- },
- 3000,
- );
- } else {
- throw Error("Profile not deleted");
- }
- } catch (error) {
+ } catch (error: unknown) {
+ const errorMessage = this.handleAxiosError(error);
logConsoleAndDb("Error deleting profile: " + errorStringForLog(error));
- const errorMessage: string =
- error.response?.data?.error?.message ||
- error.response?.data?.error ||
- error.message ||
- "There was an error deleting your profile.";
this.$notify(
{
group: "alert",
@@ -2024,5 +1893,54 @@ export default class AccountViewView extends Vue {
this.savingProfile = false;
}
}
+
+ notifyDownloadStarted() {
+ this.$notify(
+ {
+ group: "alert",
+ type: "success",
+ title: "Download Started",
+ text: "See your downloads directory for the backup. It is in the Dexie format.",
+ },
+ -1,
+ );
+ }
+
+ showNameDialog() {
+ (this.$refs.userNameDialog as UserNameDialog).open((name?: string) => {
+ if (name) {
+ this.givenName = name;
+ }
+ });
+ }
+
+ computedStartDownloadLinkClassNames(): string {
+ return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md";
+ }
+
+ computedDownloadLinkClassNames(): string {
+ return "block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6";
+ }
+
+ // Update error handling for type safety
+ private handleAxiosError(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ if (typeof error === "object" && error !== null) {
+ const err = error as {
+ response?: { data?: { error?: { message?: string } } };
+ };
+ return err.response?.data?.error?.message || "An unknown error occurred";
+ }
+ return "An unknown error occurred";
+ }
+
+ handleProfileUpdate(updatedProfile: UserProfile) {
+ this.userProfileDesc = updatedProfile.description;
+ this.userProfileLatitude = updatedProfile.location?.lat || 0;
+ this.userProfileLongitude = updatedProfile.location?.lng || 0;
+ this.includeUserProfileLocation = !!updatedProfile.location;
+ }
}
diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue
index ddbf0ab3..96371dd2 100644
--- a/src/views/ProjectsView.vue
+++ b/src/views/ProjectsView.vue
@@ -279,11 +279,7 @@ import ProjectIcon from "../components/ProjectIcon.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { Contact } from "../db/tables/contacts";
-import {
- didInfo,
- getHeaders,
- getPlanFromCache,
-} from "../libs/endorserServer";
+import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
import * as libsUtil from "../libs/util";
import { OnboardPage } from "../libs/util";
diff --git a/tsconfig.json b/tsconfig.json
index b973a9d6..9b7b3936 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,7 +18,8 @@
"@/db/*": ["db/*"],
"@/libs/*": ["libs/*"],
"@/constants/*": ["constants/*"],
- "@/store/*": ["store/*"]
+ "@/store/*": ["store/*"],
+ "@/types/*": ["types/*"]
},
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs
},
diff --git a/vite.config.base.ts b/vite.config.base.ts
new file mode 100644
index 00000000..c3702bc8
--- /dev/null
+++ b/vite.config.base.ts
@@ -0,0 +1,46 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import path from "path";
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ 'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
+ 'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
+ 'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
+ stream: 'stream-browserify',
+ util: 'util',
+ crypto: 'crypto-browserify'
+ },
+ mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
+ },
+ optimizeDeps: {
+ include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
+ esbuildOptions: {
+ define: {
+ global: 'globalThis'
+ }
+ }
+ },
+ build: {
+ sourcemap: true,
+ target: 'esnext',
+ chunkSizeWarningLimit: 1000,
+ commonjsOptions: {
+ include: [/node_modules/],
+ transformMixedEsModules: true
+ },
+ rollupOptions: {
+ external: ['stream', 'util', 'crypto'],
+ output: {
+ globals: {
+ stream: 'stream',
+ util: 'util',
+ crypto: 'crypto'
+ }
+ }
+ }
+ }
+});
\ No newline at end of file
diff --git a/vite.config.mobile.ts b/vite.config.mobile.ts
new file mode 100644
index 00000000..54641d07
--- /dev/null
+++ b/vite.config.mobile.ts
@@ -0,0 +1,28 @@
+import { defineConfig, loadEnv } from "vite";
+import baseConfig from "./vite.config.base";
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+
+ return {
+ ...baseConfig,
+ define: {
+ 'import.meta.env.VITE_PLATFORM': JSON.stringify('mobile'),
+ },
+ build: {
+ ...baseConfig.build,
+ outDir: 'dist/mobile',
+ rollupOptions: {
+ ...baseConfig.build.rollupOptions,
+ output: {
+ ...baseConfig.build.rollupOptions.output,
+ manualChunks: {
+ // Mobile-specific chunk splitting
+ vendor: ['vue', 'vue-router', 'pinia'],
+ capacitor: ['@capacitor/core', '@capacitor/filesystem', '@capacitor/share'],
+ }
+ }
+ }
+ }
+ };
+});
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index c3702bc8..0e8918dd 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,46 +1,18 @@
-import { defineConfig } from "vite";
-import vue from "@vitejs/plugin-vue";
-import path from "path";
+import { defineConfig, loadEnv } from "vite";
+import baseConfig from "./vite.config.base";
-export default defineConfig({
- plugins: [vue()],
- resolve: {
- alias: {
- '@': path.resolve(__dirname, './src'),
- 'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
- 'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
- 'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
- stream: 'stream-browserify',
- util: 'util',
- crypto: 'crypto-browserify'
- },
- mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
- },
- optimizeDeps: {
- include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
- esbuildOptions: {
- define: {
- global: 'globalThis'
- }
- }
- },
- build: {
- sourcemap: true,
- target: 'esnext',
- chunkSizeWarningLimit: 1000,
- commonjsOptions: {
- include: [/node_modules/],
- transformMixedEsModules: true
- },
- rollupOptions: {
- external: ['stream', 'util', 'crypto'],
- output: {
- globals: {
- stream: 'stream',
- util: 'util',
- crypto: 'crypto'
- }
- }
- }
- }
+// https://vitejs.dev/config/
+export default defineConfig(({ mode }) => {
+ // Load env file based on `mode` in the current working directory.
+ // Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
+ const env = loadEnv(mode, process.cwd(), '');
+ const platform = env.PLATFORM || 'web';
+
+ // Load platform-specific config
+ const platformConfig = require(`./vite.config.${platform}`).default;
+
+ return {
+ ...baseConfig,
+ ...platformConfig,
+ };
});
\ No newline at end of file
diff --git a/vite.config.web.ts b/vite.config.web.ts
new file mode 100644
index 00000000..f7376975
--- /dev/null
+++ b/vite.config.web.ts
@@ -0,0 +1,27 @@
+import { defineConfig, loadEnv } from "vite";
+import baseConfig from "./vite.config.base";
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+
+ return {
+ ...baseConfig,
+ define: {
+ 'import.meta.env.VITE_PLATFORM': JSON.stringify('web'),
+ },
+ build: {
+ ...baseConfig.build,
+ outDir: 'dist/web',
+ rollupOptions: {
+ ...baseConfig.build.rollupOptions,
+ output: {
+ ...baseConfig.build.rollupOptions.output,
+ manualChunks: {
+ // Web-specific chunk splitting
+ vendor: ['vue', 'vue-router', 'pinia'],
+ }
+ }
+ }
+ }
+ };
+});
\ No newline at end of file