forked from jsnbuchanan/crowd-funder-for-time-pwa
Merge branch 'master' into gifting-ui-2025-05
This commit is contained in:
@@ -14,22 +14,34 @@
|
||||
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="record.issuerDid">
|
||||
<router-link
|
||||
v-if="record.issuerDid && !isHiddenDid(record.issuerDid)"
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(record.issuerDid),
|
||||
}"
|
||||
title="More details about this person"
|
||||
>
|
||||
<EntityIcon
|
||||
:entity-id="record.issuerDid"
|
||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[2rem]"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else-if="isHiddenDid(record.issuerDid)"
|
||||
icon="eye-slash"
|
||||
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||
@click="notifyHiddenPerson"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||
@click="notifyUnknownPerson"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ record.issuer.known ? record.issuer.displayName : "" }}
|
||||
<h3 v-if="record.issuer.known" class="font-semibold leading-tight">
|
||||
{{ record.issuer.displayName }}
|
||||
</h3>
|
||||
<p class="ms-auto text-xs text-slate-500 italic">
|
||||
{{ friendlyDate }}
|
||||
@@ -37,7 +49,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
|
||||
<a
|
||||
class="cursor-pointer"
|
||||
data-testid="circle-info-link"
|
||||
@click="$emit('loadClaim', record.jwtId)"
|
||||
>
|
||||
<font-awesome icon="circle-info" class="fa-fw text-slate-500" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -46,7 +62,7 @@
|
||||
<!-- Record Image -->
|
||||
<div
|
||||
v-if="record.image"
|
||||
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
||||
class="bg-cover mb-2 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
||||
:style="`background-image: url(${record.image});`"
|
||||
>
|
||||
<a
|
||||
@@ -57,34 +73,64 @@
|
||||
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
|
||||
:src="record.image"
|
||||
alt="Activity image"
|
||||
@load="$emit('cacheImage', record.image)"
|
||||
@load="cacheImage(record.image)"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="font-medium">
|
||||
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
|
||||
{{ description }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-5"
|
||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
|
||||
>
|
||||
<!-- Source -->
|
||||
<div
|
||||
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
>
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.providerPlanName">
|
||||
<ProjectIcon
|
||||
:entity-id="record.providerPlanName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
<router-link
|
||||
:to="{
|
||||
path:
|
||||
'/project/' +
|
||||
encodeURIComponent(record.providerPlanHandleId || ''),
|
||||
}"
|
||||
title="View project details"
|
||||
>
|
||||
<ProjectIcon
|
||||
:entity-id="record.providerPlanHandleId || ''"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.agentDid">
|
||||
<EntityIcon
|
||||
:entity-id="record.agentDid"
|
||||
:profile-image-url="record.issuer.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
<router-link
|
||||
v-if="!isHiddenDid(record.agentDid)"
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(record.agentDid),
|
||||
}"
|
||||
title="More details about this person"
|
||||
>
|
||||
<EntityIcon
|
||||
:entity-id="record.agentDid"
|
||||
:profile-image-url="record.issuer.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="eye-slash"
|
||||
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
|
||||
@click="notifyHiddenPerson"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unknown Person -->
|
||||
@@ -92,6 +138,7 @@
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
@click="notifyUnknownPerson"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,9 +157,11 @@
|
||||
|
||||
<!-- Arrow -->
|
||||
<div
|
||||
class="absolute inset-x-[8rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
|
||||
class="absolute inset-x-[7rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<div class="text-sm text-center leading-none font-semibold pe-[15px]">
|
||||
<div
|
||||
class="text-sm text-center leading-none font-semibold pe-2 sm:pe-4"
|
||||
>
|
||||
{{ fetchAmount }}
|
||||
</div>
|
||||
|
||||
@@ -129,24 +178,47 @@
|
||||
|
||||
<!-- Destination -->
|
||||
<div
|
||||
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
>
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.recipientProjectName">
|
||||
<ProjectIcon
|
||||
:entity-id="record.recipientProjectName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
<router-link
|
||||
:to="{
|
||||
path:
|
||||
'/project/' +
|
||||
encodeURIComponent(record.fulfillsPlanHandleId || ''),
|
||||
}"
|
||||
title="View project details"
|
||||
>
|
||||
<ProjectIcon
|
||||
:entity-id="record.fulfillsPlanHandleId || ''"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.recipientDid">
|
||||
<EntityIcon
|
||||
:entity-id="record.recipientDid"
|
||||
:profile-image-url="record.receiver.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
<router-link
|
||||
v-if="!isHiddenDid(record.recipientDid)"
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(record.recipientDid),
|
||||
}"
|
||||
title="More details about this person"
|
||||
>
|
||||
<EntityIcon
|
||||
:entity-id="record.recipientDid"
|
||||
:profile-image-url="record.receiver.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="eye-slash"
|
||||
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
|
||||
@click="notifyHiddenPerson"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unknown Person -->
|
||||
@@ -154,6 +226,7 @@
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
@click="notifyUnknownPerson"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,24 +243,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="font-medium">
|
||||
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
|
||||
{{ description }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import { GiveRecordWithContactInfo } from "../types";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||
import { containsHiddenDid } from "../libs/endorserServer";
|
||||
import { containsHiddenDid, isHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -202,6 +269,38 @@ export default class ActivityListItem extends Vue {
|
||||
@Prop() activeDid!: string;
|
||||
@Prop() confirmerIdList?: string[];
|
||||
|
||||
isHiddenDid = isHiddenDid;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
notifyHiddenPerson() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Person Outside Your Network",
|
||||
text: "This person is not visible to you.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
notifyUnknownPerson() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Unidentified Person",
|
||||
text: "Nobody specific was recognized.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
@Emit()
|
||||
cacheImage(image: string) {
|
||||
return image;
|
||||
}
|
||||
|
||||
get fetchAmount(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
@@ -217,7 +316,7 @@ export default class ActivityListItem extends Vue {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
|
||||
return `${claim.description}`;
|
||||
return `${claim?.description || ""}`;
|
||||
}
|
||||
|
||||
private displayAmount(code: string, amt: number) {
|
||||
|
||||
216
src/components/DataExportSection.vue
Normal file
216
src/components/DataExportSection.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
/** * Data Export Section Component * * Provides UI and functionality for
|
||||
exporting user data and backing up identifier seeds. * Includes buttons for seed
|
||||
backup and database export, with platform-specific download instructions. * *
|
||||
@component * @displayName DataExportSection * @example * ```vue *
|
||||
<DataExportSection :active-did="currentDid" />
|
||||
* ``` */
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="sectionDataExport"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
>
|
||||
<div class="mb-2 font-bold">Data Export</div>
|
||||
<router-link
|
||||
v-if="activeDid"
|
||||
:to="{ name: 'seed-backup' }"
|
||||
class="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 mb-2 mt-2"
|
||||
>
|
||||
Backup Identifier Seed
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
:class="computedStartDownloadLinkClassNames()"
|
||||
class="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"
|
||||
@click="exportDatabase()"
|
||||
>
|
||||
Download Contacts
|
||||
</button>
|
||||
<a
|
||||
ref="downloadLink"
|
||||
:class="computedDownloadLinkClassNames()"
|
||||
class="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"
|
||||
>
|
||||
If no download happened yet, click again here to download now.
|
||||
</a>
|
||||
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4">
|
||||
<p>
|
||||
After the download, you can save the file in your preferred storage
|
||||
location.
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
v-if="platformCapabilities.isIOS"
|
||||
class="list-disc list-outside ml-4"
|
||||
>
|
||||
On iOS: You will be prompted to choose a location to save your backup
|
||||
file.
|
||||
</li>
|
||||
<li
|
||||
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
|
||||
class="list-disc list-outside ml-4"
|
||||
>
|
||||
On Android: You will be prompted to choose a location to save your
|
||||
backup file.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import {
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
} from "../services/PlatformService";
|
||||
import { contactsToExportJson } from "../libs/util";
|
||||
|
||||
/**
|
||||
* @vue-component
|
||||
* Data Export Section Component
|
||||
* Handles database export and seed backup functionality with platform-specific behavior
|
||||
*/
|
||||
@Component
|
||||
export default class DataExportSection extends Vue {
|
||||
/**
|
||||
* Notification function injected by Vue
|
||||
* Used to show success/error messages to the user
|
||||
*/
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
/**
|
||||
* Active DID (Decentralized Identifier) of the user
|
||||
* Controls visibility of seed backup option
|
||||
* @required
|
||||
*/
|
||||
@Prop({ required: true }) readonly activeDid!: string;
|
||||
|
||||
/**
|
||||
* URL for the database export download
|
||||
* Created and revoked dynamically during export process
|
||||
* Only used in web platform
|
||||
*/
|
||||
downloadUrl = "";
|
||||
|
||||
/**
|
||||
* Platform service instance for platform-specific operations
|
||||
*/
|
||||
private platformService: PlatformService =
|
||||
PlatformServiceFactory.getInstance();
|
||||
|
||||
/**
|
||||
* Platform capabilities for the current platform
|
||||
*/
|
||||
private get platformCapabilities(): PlatformCapabilities {
|
||||
return this.platformService.getCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook to clean up resources
|
||||
* Revokes object URL when component is unmounted (web platform only)
|
||||
*/
|
||||
beforeUnmount() {
|
||||
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
|
||||
URL.revokeObjectURL(this.downloadUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the database to a JSON file
|
||||
* Uses platform-specific methods for saving the exported data
|
||||
* Shows success/error notifications to user
|
||||
*
|
||||
* @throws {Error} If export fails
|
||||
* @emits {Notification} Success or error notification
|
||||
*/
|
||||
public async exportDatabase() {
|
||||
try {
|
||||
let allContacts: Contact[] = [];
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
|
||||
if (result) {
|
||||
allContacts = databaseUtil.mapQueryResultToValues(
|
||||
result,
|
||||
) as unknown as Contact[];
|
||||
}
|
||||
// if (USE_DEXIE_DB) {
|
||||
// await db.open();
|
||||
// allContacts = await db.contacts.toArray();
|
||||
// }
|
||||
|
||||
// Convert contacts to export format
|
||||
const exportData = contactsToExportJson(allContacts);
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: "application/json" });
|
||||
|
||||
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts.json`;
|
||||
|
||||
if (this.platformCapabilities.hasFileDownload) {
|
||||
// Web platform: Use download link
|
||||
this.downloadUrl = URL.createObjectURL(blob);
|
||||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
||||
downloadAnchor.href = this.downloadUrl;
|
||||
downloadAnchor.download = fileName;
|
||||
downloadAnchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
|
||||
} else if (this.platformCapabilities.hasFileSystem) {
|
||||
// Native platform: Write to app directory
|
||||
await this.platformService.writeAndShareFile(fileName, jsonStr);
|
||||
} else {
|
||||
throw new Error("This platform does not support file downloads.");
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Export Successful",
|
||||
text: this.platformCapabilities.hasFileDownload
|
||||
? "See your downloads directory for the backup."
|
||||
: "The backup file has been saved.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Export Error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
text: "There was an error exporting the data.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes class names for the initial download button
|
||||
* @returns Object with 'hidden' class when download is in progress (web platform only)
|
||||
*/
|
||||
public computedStartDownloadLinkClassNames() {
|
||||
return {
|
||||
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes class names for the secondary download link
|
||||
* @returns Object with 'hidden' class when no download is available or not on web platform
|
||||
*/
|
||||
public computedDownloadLinkClassNames() {
|
||||
return {
|
||||
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -99,6 +99,9 @@ import {
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { USE_DEXIE_DB } from "@/constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
|
||||
@@ -122,7 +125,10 @@ export default class FeedFilters extends Vue {
|
||||
async open(onCloseIfChanged: () => void) {
|
||||
this.onCloseIfChanged = onCloseIfChanged;
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.hasVisibleDid = !!settings.filterFeedByVisible;
|
||||
this.isNearby = !!settings.filterFeedByNearby;
|
||||
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
|
||||
@@ -136,17 +142,29 @@ export default class FeedFilters extends Vue {
|
||||
async toggleHasVisibleDid() {
|
||||
this.settingChanged = true;
|
||||
this.hasVisibleDid = !this.hasVisibleDid;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await databaseUtil.updateDefaultSettings({
|
||||
filterFeedByVisible: this.hasVisibleDid,
|
||||
});
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByVisible: this.hasVisibleDid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async toggleNearby() {
|
||||
this.settingChanged = true;
|
||||
this.isNearby = !this.isNearby;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await databaseUtil.updateDefaultSettings({
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async clearAll() {
|
||||
@@ -154,11 +172,18 @@ export default class FeedFilters extends Vue {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await databaseUtil.updateDefaultSettings({
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.hasVisibleDid = false;
|
||||
this.isNearby = false;
|
||||
}
|
||||
@@ -168,11 +193,18 @@ export default class FeedFilters extends Vue {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await databaseUtil.updateDefaultSettings({
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.hasVisibleDid = true;
|
||||
this.isNearby = true;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
@@ -186,7 +186,10 @@ import {
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
|
||||
@Component({
|
||||
@@ -210,7 +213,6 @@ export default class GiftedDialog extends Vue {
|
||||
customTitle?: string;
|
||||
description = "";
|
||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||
isTrade = false;
|
||||
offerId = "";
|
||||
prompt = "";
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
@@ -239,11 +241,23 @@ export default class GiftedDialog extends Vue {
|
||||
this.currentStep = giver ? 2 : 1;
|
||||
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
|
||||
if (result) {
|
||||
this.allContacts = databaseUtil.mapQueryResultToValues(
|
||||
result,
|
||||
) as unknown as Contact[];
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
}
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
@@ -397,15 +411,12 @@ export default class GiftedDialog extends Vue {
|
||||
unitCode,
|
||||
this.toProjectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
false,
|
||||
undefined,
|
||||
this.fromProjectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
@@ -423,7 +434,7 @@ export default class GiftedDialog extends Vue {
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
||||
text: `That gift was recorded.`,
|
||||
},
|
||||
7000,
|
||||
);
|
||||
@@ -452,15 +463,6 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
|
||||
@@ -74,10 +74,12 @@
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component
|
||||
export default class GivenPrompts extends Vue {
|
||||
@@ -127,8 +129,16 @@ export default class GivenPrompts extends Vue {
|
||||
this.visible = true;
|
||||
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
|
||||
|
||||
await db.open();
|
||||
this.numContacts = await db.contacts.count();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(
|
||||
"SELECT COUNT(*) FROM contacts",
|
||||
);
|
||||
if (result) {
|
||||
this.numContacts = result.values[0][0] as number;
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
this.numContacts = await db.contacts.count();
|
||||
}
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
|
||||
}
|
||||
|
||||
@@ -217,6 +227,7 @@ export default class GivenPrompts extends Vue {
|
||||
|
||||
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
|
||||
let count = 0;
|
||||
|
||||
// as long as the index has an entry, loop
|
||||
while (
|
||||
this.shownContactDbIndices[someContactDbIndex] != null &&
|
||||
@@ -229,10 +240,21 @@ export default class GivenPrompts extends Vue {
|
||||
this.nextIdeaPastContacts();
|
||||
} else {
|
||||
// get the contact at that offset
|
||||
await db.open();
|
||||
this.currentContact = await db.contacts
|
||||
.offset(someContactDbIndex)
|
||||
.first();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery(
|
||||
"SELECT * FROM contacts LIMIT 1 OFFSET ?",
|
||||
[someContactDbIndex],
|
||||
);
|
||||
if (result) {
|
||||
const mappedContacts = databaseUtil.mapQueryResultToValues(result);
|
||||
this.currentContact = mappedContacts[0] as unknown as Contact;
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.open();
|
||||
this.currentContact = await db.contacts
|
||||
.offset(someContactDbIndex)
|
||||
.first();
|
||||
}
|
||||
this.shownContactDbIndices[someContactDbIndex] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,7 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a
|
||||
:href="`/did/${visDid}`"
|
||||
target="_blank"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
|
||||
@@ -1,108 +1,413 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
<span v-if="uploading">Uploading Image…</span>
|
||||
<span v-else-if="blob">{{
|
||||
crop ? "Crop Image" : "Preview Image"
|
||||
}}</span>
|
||||
<span v-else-if="showCameraPreview">Upload Image</span>
|
||||
<span v-else>Add Photo</span>
|
||||
</h1>
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
Add Photo
|
||||
</div>
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-center mt-8">
|
||||
<div>
|
||||
<font-awesome
|
||||
icon="camera"
|
||||
class="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-2 py-2 rounded-md"
|
||||
@click="openPhotoDialog()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input type="file" @change="uploadImageFile" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2">
|
||||
... or paste a URL:
|
||||
<input v-model="imageUrl" type="text" class="border-2" />
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
<font-awesome
|
||||
v-if="imageUrl"
|
||||
icon="check"
|
||||
class="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-2 py-2 rounded-md cursor-pointer"
|
||||
@click="acceptUrl"
|
||||
/>
|
||||
<!-- so that there's no shifting when it becomes visible -->
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="check"
|
||||
class="text-white bg-white px-2 py-2"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<!-- FEEDBACK: Show if camera preview is not visible after mounting -->
|
||||
<div
|
||||
v-if="!showCameraPreview && !blob && isRegistered"
|
||||
class="bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm"
|
||||
>
|
||||
<strong>Camera preview not started.</strong>
|
||||
<div v-if="cameraState === 'off'">
|
||||
<span v-if="platformCapabilities.isMobile">
|
||||
<b>Note:</b> This mobile browser may not support direct camera
|
||||
access, or the app is treating it as a native app.<br />
|
||||
<b>Tip:</b> Try using a desktop browser, or check if your browser
|
||||
supports camera access for web apps.<br />
|
||||
<b>Developer:</b> The platform detection logic may be skipping
|
||||
camera preview for mobile browsers. <br />
|
||||
<b>Action:</b> Review <code>platformCapabilities.isMobile</code> and
|
||||
ensure web browsers on mobile are not treated as native apps.
|
||||
</span>
|
||||
<span v-else>
|
||||
<b>Tip:</b> Your browser supports camera APIs, but the preview did
|
||||
not start. Try refreshing the page or checking browser permissions.
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="cameraState === 'error'">
|
||||
<b>Error:</b> {{ error || cameraStateMessage }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<b>Status:</b> {{ cameraStateMessage || "Unknown reason." }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<template v-if="isRegistered">
|
||||
<div v-if="!blob">
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
Take a photo with your camera
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCameraPreview"
|
||||
class="camera-preview relative flex bg-black overflow-hidden mb-4"
|
||||
>
|
||||
<!-- Diagnostic Panel -->
|
||||
<div
|
||||
v-if="showDiagnostics"
|
||||
class="absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p><strong>Camera State:</strong> {{ cameraState }}</p>
|
||||
<p>
|
||||
<strong>State Message:</strong>
|
||||
{{ cameraStateMessage || "None" }}
|
||||
</p>
|
||||
<p><strong>Error:</strong> {{ error || "None" }}</p>
|
||||
<p>
|
||||
<strong>Preview Active:</strong>
|
||||
{{ showCameraPreview ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Stream Active:</strong>
|
||||
{{ !!cameraStream ? "Yes" : "No" }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Browser:</strong> {{ userAgent }}</p>
|
||||
<p>
|
||||
<strong>HTTPS:</strong>
|
||||
{{ isSecureContext ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>MediaDevices:</strong>
|
||||
{{ hasMediaDevices ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>GetUserMedia:</strong>
|
||||
{{ hasGetUserMedia ? "Yes" : "No" }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Platform:</strong>
|
||||
{{ platformCapabilities.isMobile ? "Mobile" : "Desktop" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Diagnostics Button -->
|
||||
<button
|
||||
class="absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30"
|
||||
@click="toggleDiagnostics"
|
||||
>
|
||||
{{ showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" }}
|
||||
</button>
|
||||
<div class="camera-container w-full h-full relative">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video w-full h-full object-cover"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<div
|
||||
class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4"
|
||||
>
|
||||
<button
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<button
|
||||
v-if="platformCapabilities.isMobile"
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="rotateCamera"
|
||||
>
|
||||
<font-awesome icon="rotate" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
OR choose a file from your device
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="file"
|
||||
class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
|
||||
@change="uploadImageFile"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
OR paste an image URL
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<input
|
||||
v-model="imageUrl"
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-4 py-2"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
<button
|
||||
v-if="imageUrl"
|
||||
class="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-3 py-2 rounded-md cursor-pointer"
|
||||
@click="acceptUrl"
|
||||
>
|
||||
<font-awesome icon="check" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="uploading" class="flex justify-center">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin fa-3x text-center block px-12 py-12"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="crop">
|
||||
<VuePictureCropper
|
||||
:box-style="{
|
||||
backgroundColor: '#f8f8f8',
|
||||
margin: 'auto',
|
||||
}"
|
||||
:img="createBlobURL(blob)"
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 1 / 1,
|
||||
}"
|
||||
class="max-h-[50vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="createBlobURL(blob)"
|
||||
class="mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-2 mt-2',
|
||||
showRetry ? 'grid-cols-2' : 'grid-cols-1',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="uploadImage"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showRetry"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="retryImage"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
id="noticeBeforeUpload"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="mb-2">
|
||||
Before you can upload a photo, a friend needs to register you.
|
||||
</p>
|
||||
<button
|
||||
class="inline-block text-md uppercase 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-4 py-2 rounded-md"
|
||||
@click="handleQRCodeClick"
|
||||
>
|
||||
Share Your Info
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoDialog ref="photoDialog" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import { ref } from "vue";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import PhotoDialog from "../components/PhotoDialog.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import {
|
||||
DEFAULT_IMAGE_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
} from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>();
|
||||
|
||||
@Component({
|
||||
components: { PhotoDialog },
|
||||
components: { VuePictureCropper },
|
||||
props: {
|
||||
isRegistered: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultCameraMode: {
|
||||
type: String,
|
||||
default: "environment",
|
||||
validator: (value: string) => ["environment", "user"].includes(value),
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
claimType: string;
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
|
||||
/** Current image blob being processed */
|
||||
blob?: Blob;
|
||||
|
||||
/** Type of claim for the image */
|
||||
claimType: string = "";
|
||||
|
||||
/** Whether to show cropping interface */
|
||||
crop: boolean = false;
|
||||
|
||||
/** Name of the selected file */
|
||||
fileName?: string;
|
||||
|
||||
/** Callback function to set image URL after upload */
|
||||
imageCallback: (imageUrl?: string) => void = () => {};
|
||||
|
||||
/** URL for image input */
|
||||
imageUrl?: string;
|
||||
|
||||
/** Whether to show retry button */
|
||||
showRetry = true;
|
||||
|
||||
/** Upload progress state */
|
||||
uploading = false;
|
||||
|
||||
/** Dialog visibility state */
|
||||
visible = false;
|
||||
|
||||
/** Whether to show camera preview */
|
||||
showCameraPreview = false;
|
||||
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
/** Current camera facing mode */
|
||||
private currentFacingMode: "environment" | "user" = "environment";
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
// Add diagnostic properties
|
||||
showDiagnostics = false;
|
||||
userAgent = navigator.userAgent;
|
||||
isSecureContext = window.isSecureContext;
|
||||
hasMediaDevices = !!navigator.mediaDevices;
|
||||
hasGetUserMedia = !!(
|
||||
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
);
|
||||
cameraState:
|
||||
| "off"
|
||||
| "initializing"
|
||||
| "ready"
|
||||
| "active"
|
||||
| "error"
|
||||
| "permission_denied"
|
||||
| "not_found"
|
||||
| "in_use" = "off";
|
||||
cameraStateMessage?: string;
|
||||
error: string | null = null;
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
try {
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
||||
*/
|
||||
beforeDestroy() {
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
logger.debug("ImageMethodDialog.open called");
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
this.currentFacingMode = this.defaultCameraMode as "environment" | "user";
|
||||
|
||||
openPhotoDialog(blob?: Blob, fileName?: string) {
|
||||
this.visible = false;
|
||||
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
blob,
|
||||
fileName,
|
||||
);
|
||||
// Start camera preview immediately
|
||||
logger.debug("Starting camera preview from open()");
|
||||
this.startCameraPreview();
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
this.visible = false;
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files) return;
|
||||
|
||||
inputImageFileNameRef.value = event.target.files[0];
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||
// ... plus it has a `type` property from my testing
|
||||
inputImageFileNameRef.value = target.files[0];
|
||||
const file = inputImageFileNameRef.value;
|
||||
if (file != null) {
|
||||
const reader = new FileReader();
|
||||
@@ -112,7 +417,9 @@ export default class ImageMethodDialog extends Vue {
|
||||
const blob = new Blob([new Uint8Array(data)], {
|
||||
type: file.type,
|
||||
});
|
||||
this.openPhotoDialog(blob, file.name as string);
|
||||
this.blob = blob;
|
||||
this.fileName = file.name;
|
||||
this.showRetry = false;
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file as Blob);
|
||||
@@ -120,21 +427,16 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
|
||||
async acceptUrl() {
|
||||
this.visible = false;
|
||||
if (this.crop) {
|
||||
try {
|
||||
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
|
||||
responseType: "blob", // This ensures the data is returned as a Blob
|
||||
const urlBlobResponse = await axios.get(this.imageUrl as string, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const fullUrl = new URL(this.imageUrl as string);
|
||||
const fileName = fullUrl.pathname.split("/").pop() as string;
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
urlBlobResponse.data as Blob,
|
||||
fileName,
|
||||
);
|
||||
this.blob = urlBlobResponse.data as Blob;
|
||||
this.fileName = fileName;
|
||||
this.showRetry = false;
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -148,11 +450,273 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
} else {
|
||||
this.imageCallback(this.imageUrl);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
this.stopCameraPreview();
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
}
|
||||
this.blob = undefined;
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
|
||||
async startCameraPreview() {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
|
||||
logger.debug(
|
||||
"getUserMedia available:",
|
||||
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
);
|
||||
|
||||
try {
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Requesting camera access...";
|
||||
this.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Camera API not available in this browser");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: this.currentFacingMode },
|
||||
});
|
||||
logger.debug("Camera access granted");
|
||||
this.cameraStream = stream;
|
||||
this.cameraState = "active";
|
||||
this.cameraStateMessage = "Camera is active";
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("Video element started playing");
|
||||
resolve(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Error playing video:", error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
throw new Error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to access camera";
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === "NotReadableError" || error.name === "TrackStartError")
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||
} else if (
|
||||
error instanceof Error &&
|
||||
(error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError")
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||
}
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage = errorMessage;
|
||||
this.error = errorMessage;
|
||||
this.showCameraPreview = false;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopCameraPreview() {
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
this.cameraState = "off";
|
||||
this.cameraStateMessage = "Camera stopped";
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
async capturePhoto() {
|
||||
if (!this.cameraStream) return;
|
||||
|
||||
try {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.showRetry = true;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error capturing photo:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to capture photo. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async rotateCamera() {
|
||||
// Toggle between front and back cameras
|
||||
this.currentFacingMode =
|
||||
this.currentFacingMode === "environment" ? "user" : "environment";
|
||||
|
||||
// Stop current stream
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
|
||||
// Start new stream with updated facing mode
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
|
||||
if (this.crop) {
|
||||
this.blob = (await cropper?.getBlob()) || undefined;
|
||||
}
|
||||
|
||||
const token = await accessToken(this.activeDid);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const formData = new FormData();
|
||||
if (!this.blob) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error finding the picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
formData.append("claimType", this.claimType);
|
||||
try {
|
||||
if (
|
||||
window.location.hostname === "localhost" &&
|
||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
||||
) {
|
||||
logger.log(
|
||||
"Using shared image API server, so only users on that server can play with images.",
|
||||
);
|
||||
}
|
||||
const response = await axios.post(
|
||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||
formData,
|
||||
{ headers },
|
||||
);
|
||||
this.uploading = false;
|
||||
|
||||
this.close();
|
||||
this.imageCallback(response.data.url as string);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "There was an error saving the picture.";
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
|
||||
if (status === 401) {
|
||||
errorMessage = "Authentication failed. Please try logging in again.";
|
||||
} else if (status === 413) {
|
||||
errorMessage = "Image file is too large. Please try a smaller image.";
|
||||
} else if (status === 415) {
|
||||
errorMessage =
|
||||
"Unsupported image format. Please try a different image.";
|
||||
} else if (status && status >= 500) {
|
||||
errorMessage = "Server error. Please try again later.";
|
||||
} else if (data?.message) {
|
||||
errorMessage = data.message;
|
||||
}
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle method
|
||||
toggleDiagnostics() {
|
||||
this.showDiagnostics = !this.showDiagnostics;
|
||||
}
|
||||
|
||||
private handleQRCodeClick() {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
this.$router.push({ name: "contact-qr-scan-full" });
|
||||
} else {
|
||||
this.$router.push({ name: "contact-qr" });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -178,5 +742,16 @@ export default class ImageMethodDialog extends Vue {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Add styles for diagnostic panel */
|
||||
.diagnostic-panel {
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({ emits: ["update:isOpen"] })
|
||||
export default class ImageViewer extends Vue {
|
||||
|
||||
@@ -172,8 +172,10 @@ import {
|
||||
} from "../libs/endorserServer";
|
||||
import { decryptMessage } from "../libs/crypto";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
interface Member {
|
||||
admitted: boolean;
|
||||
@@ -209,7 +211,10 @@ export default class MembersList extends Vue {
|
||||
contacts: Array<Contact> = [];
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.firstName = settings.firstName || "";
|
||||
@@ -296,7 +301,7 @@ export default class MembersList extends Vue {
|
||||
this.decryptedMembers.length === 0 ||
|
||||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
|
||||
) {
|
||||
return "Your password is not the same as the organizer. Reload or have them check their password.";
|
||||
return "Your password is not the same as the organizer. Retry or have them check their password.";
|
||||
} else {
|
||||
// the first (organizer) member was decrypted OK
|
||||
return "";
|
||||
@@ -337,7 +342,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Exists",
|
||||
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
|
||||
text: "They are in your contacts. To remove them, use the contacts page.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
@@ -347,7 +352,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Available",
|
||||
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
|
||||
text: "This is to add them to your contacts. To remove them later, use the contacts page.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
@@ -355,7 +360,16 @@ export default class MembersList extends Vue {
|
||||
}
|
||||
|
||||
async loadContacts() {
|
||||
this.contacts = await db.contacts.toArray();
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.dbQuery("SELECT * FROM contacts");
|
||||
if (result) {
|
||||
this.contacts = databaseUtil.mapQueryResultToValues(
|
||||
result,
|
||||
) as unknown as Contact[];
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
this.contacts = await db.contacts.toArray();
|
||||
}
|
||||
}
|
||||
|
||||
getContactFor(did: string): Contact | undefined {
|
||||
@@ -439,7 +453,14 @@ export default class MembersList extends Vue {
|
||||
if (result.success) {
|
||||
decrMember.isRegistered = true;
|
||||
if (oldContact) {
|
||||
await db.contacts.update(decrMember.did, { registered: true });
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
"UPDATE contacts SET registered = ? WHERE did = ?",
|
||||
[true, decrMember.did],
|
||||
);
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.contacts.update(decrMember.did, { registered: true });
|
||||
}
|
||||
oldContact.registered = true;
|
||||
}
|
||||
this.$notify(
|
||||
@@ -492,7 +513,14 @@ export default class MembersList extends Vue {
|
||||
name: member.name,
|
||||
};
|
||||
|
||||
await db.contacts.add(newContact);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
"INSERT INTO contacts (did, name) VALUES (?, ?)",
|
||||
[member.did, member.name],
|
||||
);
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.contacts.add(newContact);
|
||||
}
|
||||
this.contacts.push(newContact);
|
||||
|
||||
this.$notify(
|
||||
|
||||
@@ -82,12 +82,13 @@
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@@ -116,7 +117,10 @@ export default class OfferDialog extends Vue {
|
||||
this.recipientDid = recipientDid;
|
||||
this.recipientName = recipientName;
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
@@ -245,10 +249,7 @@ export default class OfferDialog extends Vue {
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isOfferCreationError(result.response)
|
||||
) {
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
logger.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
@@ -292,15 +293,6 @@ export default class OfferDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isOfferCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||
Welcome to Time Safari
|
||||
<br />
|
||||
- Showcasing Gratitude & Magnifying Time
|
||||
- Showcase Impact & Magnify Time
|
||||
<div
|
||||
class="text-lg text-center leading-none absolute right-0 -top-1"
|
||||
@click="onClickClose(true)"
|
||||
@@ -14,6 +14,9 @@
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
The feed underneath this pop-up shows the latest contributions, some from
|
||||
people and some from projects.
|
||||
|
||||
<p v-if="isRegistered" class="mt-4">
|
||||
You can now log things that you've seen:
|
||||
<span v-if="numContacts > 0">
|
||||
@@ -23,14 +26,10 @@
|
||||
<span class="bg-green-600 text-white rounded-full">
|
||||
<font-awesome icon="plus" class="fa-fw" />
|
||||
</span>
|
||||
button to express your appreciation for... whatever -- maybe thanks for
|
||||
showing you all these fascinating stories of
|
||||
<em>gratitude</em>.
|
||||
button to express your appreciation for... whatever.
|
||||
</p>
|
||||
<p v-else class="mt-4">
|
||||
The feed underneath this pop-up shows the latest gifts that others have
|
||||
recognized. Once someone registers you, you can log your appreciation,
|
||||
too.
|
||||
<p class="mt-4">
|
||||
Once someone registers you, you can log your appreciation, too.
|
||||
</p>
|
||||
|
||||
<p class="mt-4">
|
||||
@@ -201,13 +200,16 @@
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
db,
|
||||
retrieveSettingsForActiveAccount,
|
||||
updateAccountSettings,
|
||||
} from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { OnboardPage } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
computed: {
|
||||
@@ -222,7 +224,7 @@ export default class OnboardingDialog extends Vue {
|
||||
$router!: Router;
|
||||
|
||||
activeDid = "";
|
||||
firstContactName = null;
|
||||
firstContactName = "";
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
numContacts = 0;
|
||||
@@ -231,29 +233,54 @@ export default class OnboardingDialog extends Vue {
|
||||
|
||||
async open(page: OnboardPage) {
|
||||
this.page = page;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
const contacts = await db.contacts.toArray();
|
||||
this.numContacts = contacts.length;
|
||||
if (this.numContacts > 0) {
|
||||
this.firstContactName = contacts[0].name;
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const dbContacts = await platformService.dbQuery("SELECT * FROM contacts");
|
||||
if (dbContacts) {
|
||||
this.numContacts = dbContacts.values.length;
|
||||
const firstContact = dbContacts.values[0];
|
||||
const fullContact = databaseUtil.mapColumnsToValues(dbContacts.columns, [
|
||||
firstContact,
|
||||
]) as unknown as Contact;
|
||||
this.firstContactName = fullContact.name || "";
|
||||
}
|
||||
if (USE_DEXIE_DB) {
|
||||
const contacts = await db.contacts.toArray();
|
||||
this.numContacts = contacts.length;
|
||||
if (this.numContacts > 0) {
|
||||
this.firstContactName = contacts[0].name || "";
|
||||
}
|
||||
}
|
||||
this.visible = true;
|
||||
if (this.page === OnboardPage.Create) {
|
||||
// we'll assume that they've been through all the other pages
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
if (USE_DEXIE_DB) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onClickClose(done?: boolean, goHome?: boolean) {
|
||||
this.visible = false;
|
||||
if (done) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
if (USE_DEXIE_DB) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
}
|
||||
if (goHome) {
|
||||
this.$router.push({ name: "home" });
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
/** * PhotoDialog.vue - Cross-platform photo capture and selection component * *
|
||||
This component provides a unified interface for taking photos and selecting
|
||||
images * across different platforms (web, mobile) using the PlatformService. It
|
||||
supports: * - Taking photos using device camera * - Selecting images from device
|
||||
gallery * - Image cropping functionality * - Image upload to server * - Error
|
||||
handling and user feedback * * Features: * - Responsive design with mobile-first
|
||||
approach * - Cross-platform compatibility through PlatformService * - Image
|
||||
cropping with aspect ratio control * - Progress feedback during upload * -
|
||||
Comprehensive error handling * * @author Matthew Raymer * @version 1.0.0 * @file
|
||||
PhotoDialog.vue */
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
class="text-center font-bold absolute top-0 inset-x-0 px-4 py-2 bg-black/50 text-white leading-none pointer-events-none"
|
||||
>
|
||||
<span v-if="uploading"> Uploading... </span>
|
||||
<span v-else-if="blob"> Look Good? </span>
|
||||
<span v-else-if="showCameraPreview"> Take Photo </span>
|
||||
<span v-else> Say "Cheese"! </span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
class="text-lg text-center px-2 py-2 leading-none absolute right-0 top-0 text-white cursor-pointer"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
@@ -36,15 +48,10 @@
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 9 / 9,
|
||||
aspectRatio: 1 / 1,
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<!-- This gives a round cropper.
|
||||
:presetMode="{
|
||||
mode: 'round',
|
||||
}"
|
||||
-->
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-center">
|
||||
@@ -54,67 +61,55 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="uploadImage"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRetry"
|
||||
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
v-if="showRetry"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="retryImage"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="cameraContainer">
|
||||
<!--
|
||||
Camera "resolution" doesn't change how it shows on screen but rather stretches the result,
|
||||
eg. the following which just stretches it vertically:
|
||||
:resolution="{ width: 375, height: 812 }"
|
||||
-->
|
||||
<camera
|
||||
ref="camera"
|
||||
facing-mode="environment"
|
||||
autoplay
|
||||
@started="cameraStarted()"
|
||||
>
|
||||
<div
|
||||
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
|
||||
<div v-else-if="showCameraPreview" class="camera-preview">
|
||||
<div class="camera-container">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<button
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="takeImage()"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex flex-col items-center justify-center gap-4 p-4">
|
||||
<button
|
||||
v-if="isRegistered"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="startCameraPreview"
|
||||
>
|
||||
<button
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="swapMirrorClass()"
|
||||
>
|
||||
<font-awesome icon="left-right" class="w-[1em]"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
|
||||
<button
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="switchCamera()"
|
||||
>
|
||||
<font-awesome icon="rotate" class="w-[1em]"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
</camera>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<button
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="pickPhoto"
|
||||
>
|
||||
<font-awesome icon="image" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,58 +117,113 @@
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||
import {
|
||||
DEFAULT_IMAGE_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
} from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
|
||||
@Component({ components: { Camera, VuePictureCropper } })
|
||||
@Component({ components: { VuePictureCropper } })
|
||||
export default class PhotoDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDeviceNumber = 0;
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
|
||||
/** Current image blob being processed */
|
||||
blob?: Blob;
|
||||
|
||||
/** Type of claim for the image */
|
||||
claimType = "";
|
||||
|
||||
/** Whether to show cropping interface */
|
||||
crop = false;
|
||||
|
||||
/** Name of the selected file */
|
||||
fileName?: string;
|
||||
mirror = false;
|
||||
numDevices = 0;
|
||||
|
||||
/** Callback function to set image URL after upload */
|
||||
setImageCallback: (arg: string) => void = () => {};
|
||||
|
||||
/** Whether to show retry button */
|
||||
showRetry = true;
|
||||
|
||||
/** Upload progress state */
|
||||
uploading = false;
|
||||
|
||||
/** Dialog visibility state */
|
||||
visible = false;
|
||||
|
||||
/** Whether to show camera preview */
|
||||
showCameraPreview = false;
|
||||
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
isRegistered = false;
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
// logger.log("PhotoDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.activeDid = settings.activeDid || "";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
logger.error("Error retrieving settings from database:", err);
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
logger.log("isRegistered:", this.isRegistered);
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
open(
|
||||
/**
|
||||
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
||||
*/
|
||||
beforeDestroy() {
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the photo dialog with specified configuration
|
||||
* @param setImageFn - Callback function to handle image URL after upload
|
||||
* @param claimType - Type of claim for the image
|
||||
* @param crop - Whether to enable cropping
|
||||
* @param blob - Optional existing image blob
|
||||
* @param inputFileName - Optional filename for the image
|
||||
*/
|
||||
async open(
|
||||
setImageFn: (arg: string) => void,
|
||||
claimType: string,
|
||||
crop?: boolean,
|
||||
blob?: Blob, // for image upload, just to use the cropping function
|
||||
blob?: Blob,
|
||||
inputFileName?: string,
|
||||
) {
|
||||
this.visible = true;
|
||||
@@ -187,17 +237,28 @@ export default class PhotoDialog extends Vue {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = inputFileName;
|
||||
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
|
||||
this.showRetry = false;
|
||||
} else {
|
||||
this.blob = undefined;
|
||||
this.fileName = undefined;
|
||||
this.showRetry = true;
|
||||
// Start camera preview automatically if no blob is provided
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the photo dialog and resets state
|
||||
*/
|
||||
close() {
|
||||
logger.debug(
|
||||
"Dialog closing, current showCameraPreview:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
this.visible = false;
|
||||
this.stopCameraPreview();
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
@@ -205,141 +266,224 @@ export default class PhotoDialog extends Vue {
|
||||
this.blob = undefined;
|
||||
}
|
||||
|
||||
async cameraStarted() {
|
||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||
if (cameraComponent) {
|
||||
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
|
||||
this.mirror = cameraComponent.facingMode === "user";
|
||||
// figure out which device is active
|
||||
const currentDeviceId = cameraComponent.currentDeviceID();
|
||||
const devices = await cameraComponent.devices(["videoinput"]);
|
||||
this.activeDeviceNumber = devices.findIndex(
|
||||
(device) => device.deviceId === currentDeviceId,
|
||||
/**
|
||||
* Starts the camera preview
|
||||
*/
|
||||
async startCameraPreview() {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
|
||||
// If we're on a mobile device or using Capacitor, use the platform service
|
||||
if (this.platformCapabilities.isMobile) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For desktop web browsers, use our custom preview
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
// Set state before requesting camera access
|
||||
this.showCameraPreview = true;
|
||||
logger.debug("showCameraPreview set to:", this.showCameraPreview);
|
||||
|
||||
// Force a re-render
|
||||
await this.$nextTick();
|
||||
logger.debug(
|
||||
"After nextTick, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async switchCamera() {
|
||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
|
||||
const devices = await cameraComponent?.devices(["videoinput"]);
|
||||
await cameraComponent?.changeCamera(
|
||||
devices[this.activeDeviceNumber].deviceId,
|
||||
);
|
||||
}
|
||||
logger.debug("Requesting camera access...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
logger.debug("Camera access granted, setting up video element");
|
||||
this.cameraStream = stream;
|
||||
|
||||
async takeImage(/* payload: MouseEvent */) {
|
||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||
// Force another re-render after getting the stream
|
||||
await this.$nextTick();
|
||||
logger.debug(
|
||||
"After getting stream, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
|
||||
/**
|
||||
* This logic to set the image height & width correctly.
|
||||
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
|
||||
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
|
||||
* Now that I've done it, I can't explain why it works.
|
||||
*/
|
||||
let imageHeight = cameraComponent?.resolution?.height;
|
||||
let imageWidth = cameraComponent?.resolution?.width;
|
||||
const initialImageRatio = imageWidth / imageHeight;
|
||||
const windowRatio = window.innerWidth / window.innerHeight;
|
||||
if (initialImageRatio > 1 && windowRatio < 1) {
|
||||
// the image is wider than it is tall, and the window is taller than it is wide
|
||||
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
|
||||
// We're gonna force it opposite.
|
||||
imageHeight = cameraComponent?.resolution?.width;
|
||||
imageWidth = cameraComponent?.resolution?.height;
|
||||
} else if (initialImageRatio < 1 && windowRatio > 1) {
|
||||
// the image is taller than it is wide, and the window is wider than it is tall
|
||||
// Haven't seen this happen, but we'll do it just in case.
|
||||
imageHeight = cameraComponent?.resolution?.width;
|
||||
imageWidth = cameraComponent?.resolution?.height;
|
||||
}
|
||||
const newImageRatio = imageWidth / imageHeight;
|
||||
if (newImageRatio < windowRatio) {
|
||||
// the image is a taller ratio than the window, so fit the height first
|
||||
imageHeight = window.innerHeight / 2;
|
||||
imageWidth = imageHeight * newImageRatio;
|
||||
} else {
|
||||
// the image is a wider ratio than the window, so fit the width first
|
||||
imageWidth = window.innerWidth / 2;
|
||||
imageHeight = imageWidth / newImageRatio;
|
||||
}
|
||||
|
||||
// The resolution is only necessary because of that mobile portrait-orientation case.
|
||||
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
|
||||
this.blob =
|
||||
(await cameraComponent?.snapshot({
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
})) || undefined;
|
||||
// png is default
|
||||
this.fileName = "snapshot.png";
|
||||
if (!this.blob) {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
logger.debug("Video element found, setting srcObject");
|
||||
videoElement.srcObject = stream;
|
||||
// Wait for video to be ready
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
logger.debug("Video metadata loaded");
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video playback started");
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error taking the picture. Please try again.",
|
||||
text: "Failed to access camera. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the camera preview and cleans up resources
|
||||
*/
|
||||
stopCameraPreview() {
|
||||
logger.debug(
|
||||
"Stopping camera preview, current showCameraPreview:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
logger.debug(
|
||||
"After stopping, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a photo from the camera preview
|
||||
*/
|
||||
async capturePhoto() {
|
||||
if (!this.cameraStream) return;
|
||||
|
||||
try {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error capturing photo:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to capture photo. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a photo using device camera
|
||||
* @throws {Error} When camera access fails
|
||||
*/
|
||||
async takePhoto() {
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an image from device gallery
|
||||
* @throws {Error} When gallery access fails
|
||||
*/
|
||||
async pickPhoto() {
|
||||
try {
|
||||
const result = await this.platformService.pickImage();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error picking image:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to pick image. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blob URL for image preview
|
||||
* @param blob - Image blob to create URL for
|
||||
* @returns {string} Blob URL for the image
|
||||
*/
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the current image selection and restarts camera preview
|
||||
*/
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
}
|
||||
|
||||
/****
|
||||
|
||||
Here's an approach to photo capture without a library. It has similar quirks.
|
||||
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
|
||||
|
||||
<button id="start-camera" @click="cameraClicked">Start Camera</button>
|
||||
<video id="video" width="320" height="240" autoplay></video>
|
||||
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
|
||||
<canvas id="canvas" width="320" height="240"></canvas>
|
||||
|
||||
async cameraClicked() {
|
||||
const video = document.querySelector("#video");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: false,
|
||||
});
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
video.srcObject = stream;
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
photoSnapped() {
|
||||
const video = document.querySelector("#video");
|
||||
const canvas = document.querySelector("#canvas");
|
||||
if (
|
||||
canvas instanceof HTMLCanvasElement &&
|
||||
video instanceof HTMLVideoElement
|
||||
) {
|
||||
canvas
|
||||
?.getContext("2d")
|
||||
?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
// ... or set the blob:
|
||||
// canvas?.toBlob(
|
||||
// (blob) => {
|
||||
// this.blob = blob;
|
||||
// },
|
||||
// "image/jpeg",
|
||||
// 1,
|
||||
// );
|
||||
|
||||
// data url of the image
|
||||
const image_data_url = canvas?.toDataURL("image/jpeg");
|
||||
}
|
||||
}
|
||||
****/
|
||||
|
||||
/**
|
||||
* Uploads the current image to the server
|
||||
* Handles cropping if enabled and manages upload state
|
||||
* @throws {Error} When upload fails or server returns error
|
||||
*/
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
|
||||
@@ -350,11 +494,9 @@ export default class PhotoDialog extends Vue {
|
||||
const token = await accessToken(this.activeDid);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
// axios fills in Content-Type of multipart/form-data
|
||||
};
|
||||
const formData = new FormData();
|
||||
if (!this.blob) {
|
||||
// yeah, this should never happen, but it helps with subsequent type checking
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -367,7 +509,7 @@ export default class PhotoDialog extends Vue {
|
||||
this.uploading = false;
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "snapshot.png");
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
formData.append("claimType", this.claimType);
|
||||
try {
|
||||
if (
|
||||
@@ -387,14 +529,64 @@ export default class PhotoDialog extends Vue {
|
||||
|
||||
this.close();
|
||||
this.setImageCallback(response.data.url as string);
|
||||
} catch (error) {
|
||||
logger.error("Error uploading the image", error);
|
||||
} catch (error: unknown) {
|
||||
// Log the raw error first
|
||||
logger.error("Raw error object:", JSON.stringify(error, null, 2));
|
||||
|
||||
let errorMessage = "There was an error saving the picture.";
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const statusText = error.response?.statusText;
|
||||
const data = error.response?.data;
|
||||
|
||||
// Log detailed error information
|
||||
logger.error("Upload error details:", {
|
||||
status,
|
||||
statusText,
|
||||
data: JSON.stringify(data, null, 2),
|
||||
message: error.message,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
headers: error.config?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (status === 401) {
|
||||
errorMessage = "Authentication failed. Please try logging in again.";
|
||||
} else if (status === 413) {
|
||||
errorMessage = "Image file is too large. Please try a smaller image.";
|
||||
} else if (status === 415) {
|
||||
errorMessage =
|
||||
"Unsupported image format. Please try a different image.";
|
||||
} else if (status && status >= 500) {
|
||||
errorMessage = "Server error. Please try again later.";
|
||||
} else if (data?.message) {
|
||||
errorMessage = data.message;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
// Log non-Axios error with full details
|
||||
logger.error("Non-Axios error details:", {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
||||
});
|
||||
} else {
|
||||
// Log any other type of error
|
||||
logger.error("Unknown error type:", {
|
||||
error: JSON.stringify(error, null, 2),
|
||||
type: typeof error,
|
||||
});
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error saving the picture.",
|
||||
text: errorMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
@@ -402,21 +594,11 @@ export default class PhotoDialog extends Vue {
|
||||
this.blob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
swapMirrorClass() {
|
||||
this.mirror = !this.mirror;
|
||||
if (this.mirror) {
|
||||
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
|
||||
} else {
|
||||
(this.$refs.cameraContainer as HTMLElement).classList.remove(
|
||||
"mirror-video",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Dialog overlay styling */
|
||||
.dialog-overlay {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
@@ -431,19 +613,50 @@ export default class PhotoDialog extends Vue {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Dialog container styling */
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mirror-video {
|
||||
transform: scaleX(-1);
|
||||
-webkit-transform: scaleX(-1); /* For Safari */
|
||||
-moz-transform: scaleX(-1); /* For Firefox */
|
||||
-ms-transform: scaleX(-1); /* For IE */
|
||||
-o-transform: scaleX(-1); /* For Opera */
|
||||
/* Camera preview styling */
|
||||
.camera-preview {
|
||||
flex: 1;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(to bottom, #60a5fa, #2563eb);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<a
|
||||
v-if="linkToFull && imageUrl"
|
||||
v-if="linkToFullImage && imageUrl"
|
||||
:href="imageUrl"
|
||||
target="_blank"
|
||||
class="h-full w-full object-contain"
|
||||
>
|
||||
<div class="h-full w-full object-contain" v-html="generateIdenticon()" />
|
||||
<div class="h-full w-full object-contain" v-html="generateIcon()" />
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="h-full w-full object-contain"
|
||||
v-html="generateIdenticon()"
|
||||
/>
|
||||
<div v-else class="h-full w-full object-contain" v-html="generateIcon()" />
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { toSvg } from "jdenticon";
|
||||
@@ -35,9 +31,9 @@ export default class ProjectIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = 0;
|
||||
@Prop imageUrl = "";
|
||||
@Prop linkToFull = false;
|
||||
@Prop linkToFullImage = false;
|
||||
|
||||
generateIdenticon() {
|
||||
generateIcon() {
|
||||
if (this.imageUrl) {
|
||||
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
|
||||
} else {
|
||||
|
||||
@@ -102,7 +102,12 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
||||
import {
|
||||
DEFAULT_PUSH_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
} from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import {
|
||||
logConsoleAndDb,
|
||||
retrieveSettingsForActiveAccount,
|
||||
@@ -169,7 +174,10 @@ export default class PushNotificationPermission extends Vue {
|
||||
this.isVisible = true;
|
||||
this.pushType = pushType;
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
let pushUrl = DEFAULT_PUSH_SERVER;
|
||||
if (settings?.webPushServer) {
|
||||
pushUrl = settings.webPushServer;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<nav
|
||||
id="QuickNav"
|
||||
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
|
||||
<!-- Home Feed -->
|
||||
<li
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute right-5 top-3">
|
||||
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]">
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
@@ -15,7 +15,8 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
|
||||
@Component
|
||||
@@ -28,7 +29,10 @@ export default class TopMessage extends Vue {
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
if (
|
||||
settings.warnIfTestServer &&
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
|
||||
@@ -37,9 +37,11 @@
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component
|
||||
export default class UserNameDialog extends Vue {
|
||||
@@ -61,15 +63,25 @@ export default class UserNameDialog extends Vue {
|
||||
*/
|
||||
async open(aCallback?: (name?: string) => void) {
|
||||
this.callback = aCallback || this.callback;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
this.givenName = settings.firstName || "";
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
async onClickSaveChanges() {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.givenName,
|
||||
});
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
"UPDATE settings SET firstName = ? WHERE id = ?",
|
||||
[this.givenName, MASTER_SETTINGS_KEY],
|
||||
);
|
||||
if (USE_DEXIE_DB) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.givenName,
|
||||
});
|
||||
}
|
||||
this.visible = false;
|
||||
this.callback(this.givenName);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
|
||||
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { USE_DEXIE_DB } from "../../../../constants/app";
|
||||
import * as databaseUtil from "../../../../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../../../../db";
|
||||
import { getHeaders } from "../../../../libs/endorserServer";
|
||||
import { logger } from "../../../../utils/logger";
|
||||
@@ -14,7 +16,10 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
|
||||
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
const activeDid = settings.activeDid || "";
|
||||
const apiServer = settings.apiServer;
|
||||
const headers = await getHeaders(activeDid);
|
||||
|
||||
Reference in New Issue
Block a user