Files
crowd-funder-for-time-pwa/src/views/NewEditProjectView.vue
Matthew Raymer 0bcf34c703 fix: revert QR scanner to emit pattern after function prop binding fails
- Revert ContactInputForm QR scanner from function props back to emit pattern
- Remove problematic onQRScan function prop that wasn't resolving correctly
- Update ContactsView to use @qr-scan event handler instead of function prop
- Maintain debugging logs to track click events and method execution
- Keep other function props intact for other event handlers

The function prop approach for QR scanning failed due to Vue prop resolution
issues, causing the default function to be called instead of the parent handler.
Reverting to emits provides reliable parent-child communication for this case.
2025-07-18 06:40:08 +00:00

953 lines
30 KiB
Vue

<template>
<QuickNav selected="Projects"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<!-- Back -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Edit Project Idea
</h1>
</div>
<!-- Project Details -->
<!-- Image - (see design model) Empty -->
<div>
{{ errorMessage }}
</div>
<input
v-model="fullClaim.name"
type="text"
placeholder="Idea Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="flex justify-center mt-4">
<span v-if="hasImage" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<font-awesome
icon="trash-can"
class="text-red-500 fa-fw ml-8 mt-10"
@click="confirmDeleteImage"
/>
</span>
<span v-else>
<font-awesome
icon="camera"
:class="cameraIconClasses"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<input
v-model="agentDid"
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
/>
<div class="mb-4">
<p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span>
If you save this, the original project owner will no longer be able to
edit it.
<button class="text-blue-500" @click="agentDid = projectIssuerDid">
Click here to make the original owner an authorized representative.
</button>
</p>
</div>
<textarea
v-model="fullClaim.description"
placeholder="Description"
class="block w-full rounded border border-slate-400 px-3 py-2"
rows="5"
maxlength="5000"
></textarea>
<div class="text-xs text-slate-500 italic">
If you want to be contacted, be sure to include your contact information
-- just remember that this information is public and saved in a public
history.
</div>
<div class="text-xs text-slate-500 italic">
{{ descriptionCharacterCount }}
</div>
<input
v-model="fullClaim.url"
placeholder="Website"
autocapitalize="none"
class="block w-full rounded border border-slate-400 mt-4 px-3 py-2"
/>
<div>
<div class="flex items-center mt-4">
<span class="mr-2">Starts At</span>
<input
v-model="startDateInput"
placeholder="Start Date"
type="date"
class="rounded border border-slate-400 px-3 py-2"
/>
<input
v-model="startTimeInput"
:disabled="!startDateInput"
placeholder="Start Time"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
</div>
<div class="flex w-full justify-end items-center">
<span class="w-full flex justify-end items-center">
{{ timezoneDisplay }}
</span>
</div>
<div class="flex items-center">
<div class="mr-2">
<span>Ends at</span>
</div>
<input
v-model="endDateInput"
placeholder="End Date"
type="date"
class="ml-2 rounded border border-slate-400 px-3 py-2"
/>
<input
v-model="endTimeInput"
:disabled="!endDateInput"
placeholder="End Time"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
</div>
</div>
<div
class="flex items-center mt-4"
@click="includeLocation = !includeLocation"
>
<input v-model="includeLocation" type="checkbox" class="mr-2" />
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at the
place.
</p>
<l-map
ref="map"
v-model:zoom="zoom"
:center="[0, 0]"
class="!z-40 rounded-md"
@click="onMapClick"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="shouldShowMapMarker"
:lat-lng="[latitude, longitude]"
@click="confirmEraseLatLong()"
/>
</l-map>
</div>
<div v-if="shouldShowPartnerOptions" class="items-center mb-4">
<div class="flex" @click="sendToTrustroots = !sendToTrustroots">
<input v-model="sendToTrustroots" type="checkbox" class="mr-2" />
<label>Send to Trustroots</label>
<font-awesome
icon="circle-info"
class="text-blue-500 ml-2 cursor-pointer"
@click.stop="showNostrPartnerInfo"
/>
</div>
</div>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
:disabled="isHiddenSave"
:class="saveButtonClasses"
@click="onSaveProjectClick()"
>
<!-- SHOW if in idle state -->
<span :class="{ hidden: !shouldShowSaveText }">Save Project</span>
<!-- SHOW if in saving state; DISABLE button while in saving state -->
<span :class="{ hidden: !shouldShowSpinner }">
<!-- icon no worky? -->
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
Saving...</span
>
</button>
<button
type="button"
:class="cancelButtonClasses"
@click="onCancelClick()"
>
Cancel
</button>
</div>
</div>
</section>
</template>
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon";
import { finalizeEvent } from "@nostr/tools";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "@nostr/tools/nip06";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { LeafletMouseEvent } from "leaflet";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
} from "../constants/app";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers } from "../utils/notify";
import {
NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR,
NOTIFY_PROJECT_RETRIEVAL_ERROR,
NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM,
NOTIFY_PROJECT_DELETE_IMAGE_ERROR,
NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR,
NOTIFY_PROJECT_INVALID_LOCATION,
NOTIFY_PROJECT_INVALID_START_DATE,
NOTIFY_PROJECT_INVALID_END_DATE,
NOTIFY_PROJECT_SAVE_SUCCESS,
NOTIFY_PROJECT_PARTNER_LOCATION_WARNING,
NOTIFY_PROJECT_PARTNER_SEND_GENERAL_ERROR,
NOTIFY_PROJECT_DELETE_LOCATION_CONFIRM,
NOTIFY_PROJECT_NOSTR_PARTNER_INFO,
createProjectPartnerSendSuccessMessage,
createProjectPartnerSendErrorMessage,
PROJECT_TIMEOUT_VERY_LONG,
} from "../constants/notifications";
import { PlanActionClaim } from "../interfaces/claims";
import {
createEndorserJwtVcFromClaim,
getHeaders,
} from "../libs/endorserServer";
import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "../libs/util";
import {
EventTemplate,
UnsignedEvent,
VerifiedEvent,
serializeEvent,
} from "@nostr/tools";
import { logger } from "../utils/logger";
/**
* @fileoverview NewEditProjectView - Project Creation and Editing Interface
*
* This component provides a comprehensive interface for creating and editing project ideas
* within the TimeSafari ecosystem. It supports rich project data including images, locations,
* dates, and integration with external partner services.
*
* Key Features:
* - Project CRUD operations (create, read, update, delete)
* - Rich form fields for comprehensive project information
* - Image upload and management with deletion capabilities
* - Interactive map integration for location selection
* - Partner service integration (Trustroots, TripHopping)
* - Date/time validation and timezone handling
* - Cryptographic signing for partner authentication
* - Comprehensive error handling and user feedback
*
* Enhanced Triple Migration Pattern Status:
* ✅ Phase 1: Database Migration - PlatformServiceMixin integration
* ⏳ Phase 2: SQL Abstraction - No raw SQL queries to migrate
* ⏳ Phase 3: Notification Migration - 16 notification calls to standardize
* ⏳ Phase 4: Template Streamlining - Multiple candidates for computed properties
*
* External Dependencies:
* - Leaflet Maps: Geographic location selection
* - Axios: API communication for project and image operations
* - Luxon: Date/time manipulation and timezone handling
* - Nostr Tools: Cryptographic signing for partner services
* - Image API: Upload and deletion of project images
*
* Security: Component handles sensitive cryptographic operations for partner
* integration and requires proper authentication for all API operations.
*
* @component NewEditProjectView
* @requires PlatformServiceMixin - Database operations and account management
* @requires ImageMethodDialog - Image upload and management
* @requires QuickNav - Navigation component
* @requires Leaflet - Interactive map functionality
* @author TimeSafari Development Team
* @since 2024-01-01
* @version 1.0.0
* @migrated 2025-07-09 (Enhanced Triple Migration Pattern - Phase 1)
*/
@Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
mixins: [PlatformServiceMixin],
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
// Notification helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Display error notification to user
* Provides consistent error messaging with 5-second timeout
* @param message - Error message to display
*/
errNote(message: string) {
this.notify.error(message);
}
// Component state properties
activeDid = "";
agentDid = "";
apiServer = "";
endDateInput?: string;
endTimeInput?: string;
errorMessage = "";
fullClaim: PlanActionClaim = {
"@context": "https://schema.org",
"@type": "PlanAction",
name: "",
description: "",
}; // this default is only to avoid errors before plan is loaded
imageUrl = "";
includeLocation = false;
isHiddenSave = false;
isHiddenSpinner = true;
lastClaimJwtId = "";
latitude = 0;
longitude = 0;
numAccounts = 0;
projectId = "";
projectIssuerDid = "";
sendToTrustroots = false;
sendToTripHopping = false;
showGeneralAdvanced = false;
startDateInput?: string;
startTimeInput?: string;
zoneName = DateTime.local().zoneName;
zoom = 2;
/**
* Component lifecycle hook - Initialize project editing interface
* Loads user account information and project data if editing existing project
* Handles account validation and project loading with comprehensive error handling
*/
async mounted() {
// Initialize notification helpers
this.notify = createNotifyHelpers(this.$notify);
this.numAccounts = await retrieveAccountCount();
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route.query["projectId"] as string) || "";
if (this.projectId) {
if (this.numAccounts === 0) {
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
} else {
this.loadProject(this.activeDid);
}
}
}
/**
* Load existing project data for editing
* Retrieves project information from the API and populates form fields
* @param userDid - User's decentralized identifier
*/
async loadProject(userDid: string) {
const url =
this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
const headers = await getHeaders(userDid);
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.projectIssuerDid = resp.data.issuer;
this.fullClaim = resp.data.claim;
this.imageUrl = resp.data.claim.image || "";
this.lastClaimJwtId = resp.data.id;
if (this.fullClaim?.location) {
this.includeLocation = true;
this.latitude = this.fullClaim.location.geo.latitude;
this.longitude = this.fullClaim.location.geo.longitude;
}
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
).toLocal();
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm");
}
if (this.fullClaim.endTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.endTime as string,
).toLocal();
this.endDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.endTimeInput = localDateTime.toFormat("HH:mm");
}
}
} catch (error) {
logger.error("Got error retrieving that project", error);
this.notify.error(NOTIFY_PROJECT_RETRIEVAL_ERROR.message);
}
}
/**
* Open image upload dialog
* Integrates with ImageMethodDialog component for image selection and upload
*/
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "PlanAction");
}
/**
* Confirm image deletion with user
* Shows confirmation dialog before proceeding with image deletion
*/
confirmDeleteImage() {
this.notify.confirm(
NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM.message,
async () => {
await this.deleteImage();
},
);
}
/**
* Delete project image from server
* Handles API call to delete image and updates component state
* Includes comprehensive error handling for various failure scenarios
*/
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
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 this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{ headers },
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
logger.error("Problem deleting image:", response);
this.notify.error(NOTIFY_PROJECT_DELETE_IMAGE_ERROR.message);
return;
}
this.imageUrl = "";
} catch (error) {
logger.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
logger.log("The image was already deleted:", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.notify.error(NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR.message);
}
}
}
/**
* Save project data to server
* Handles project creation and editing with comprehensive validation
* Includes partner service integration and error handling
*/
private async saveProject() {
// Make a claim
const vcClaim: PlanActionClaim = this.fullClaim;
if (this.projectId) {
vcClaim.lastClaimId = this.lastClaimJwtId;
}
if (this.agentDid) {
vcClaim.agent = {
identifier: this.agentDid,
};
} else {
delete vcClaim.agent;
}
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
delete vcClaim.image;
}
if (this.includeLocation) {
if (!this.latitude || !this.longitude) {
this.notify.error(NOTIFY_PROJECT_INVALID_LOCATION.message);
delete vcClaim.location;
} else {
vcClaim.location = {
geo: {
"@type": "GeoCoordinates",
latitude: this.latitude,
longitude: this.longitude,
},
};
}
} else {
delete vcClaim.location;
}
if (this.startDateInput) {
try {
const startTimeFull = this.startTimeInput || "00:00:00";
const fullTimeString = this.startDateInput + " " + startTimeFull;
// throw an error on an invalid date or time string
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.startTime;
this.notify.error(NOTIFY_PROJECT_INVALID_START_DATE.message);
}
} else {
delete vcClaim.startTime;
}
if (this.endDateInput) {
try {
const endTimeFull = this.endTimeInput || "23:59:59";
const fullTimeString = this.endDateInput + " " + endTimeFull;
// throw an error on an invalid date or time string
vcClaim.endTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.endTime;
this.notify.error(NOTIFY_PROJECT_INVALID_END_DATE.message);
}
} else {
delete vcClaim.endTime;
}
const vcJwt = await createEndorserJwtVcFromClaim(this.activeDid, vcClaim);
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const headers = await getHeaders(this.activeDid);
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) {
this.notify.success(NOTIFY_PROJECT_SAVE_SUCCESS.message);
this.errorMessage = "";
const projectPath = encodeURIComponent(resp.data.success.handleId);
if (this.sendToTrustroots || this.sendToTripHopping) {
if (this.latitude && this.longitude) {
let payloadAndKey; // sign something to prove ownership of pubkey
if (this.sendToTrustroots) {
payloadAndKey = await this.signSomePayload();
// not going to await... the save was successful, so we'll continue to the next page
this.sendToNostrPartner(
"NOSTR-EVENT-TRUSTROOTS",
"Trustroots",
resp.data.success.claimId,
payloadAndKey.signedEvent,
payloadAndKey.publicExtendedKey,
);
}
if (this.sendToTripHopping) {
if (!payloadAndKey) {
payloadAndKey = await this.signSomePayload();
}
// not going to await... the save was successful, so we'll continue to the next page
this.sendToNostrPartner(
"NOSTR-EVENT-TRIPHOPPING",
"TripHopping",
resp.data.success.claimId,
payloadAndKey.signedEvent,
payloadAndKey.publicExtendedKey,
);
}
} else {
this.notify.error(NOTIFY_PROJECT_PARTNER_LOCATION_WARNING.message);
}
}
this.$router.push({ path: "/project/" + projectPath });
} else {
logger.error("Got unexpected 'data' inside response from server", resp);
let userMessage = JSON.stringify(resp.data);
if (resp.data?.error?.message) {
userMessage = resp.data.error.message;
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
}
} catch (error) {
logger.error("Got error saving project", error);
let userMessage = "There was an error saving the project.";
if (error instanceof AxiosError) {
if (error.response?.status === 400) {
userMessage = "The project information was invalid.";
} else if (error.response?.status === 401) {
userMessage = "You are not authorized to perform this action.";
} else if (error.response?.status === 403) {
userMessage = "You are not authorized to edit this project.";
} else if (error.response?.status === 404) {
userMessage = "The project was not found.";
} else if (error.response?.status === 409) {
userMessage = "There was a conflict with the project data.";
} else if (error.response?.status === 422) {
userMessage = "The project data was invalid.";
} else if (error.response?.status === 500) {
userMessage = "There was a server error.";
}
if (error.response?.data?.error?.message) {
userMessage = error.response.data.error.message;
}
}
if (userMessage) {
this.notify.error(userMessage);
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
}
}
/**
* Generate signed payload for partner authentication
* Creates cryptographic signature for external partner services
* @returns Promise containing signed event and public extended key
*/
private async signSomePayload(): Promise<{
signedEvent: VerifiedEvent;
publicExtendedKey: string;
}> {
const account = await retrieveFullyDecryptedAccount(this.activeDid);
// get the last number of the derivationPath
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
// remove any trailing '
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
const accountNum = Number(finalDerNumNoApostrophe || 0);
const extPubPri = extendedKeysFromSeedWords(
account?.mnemonic as string,
"",
accountNum,
);
const publicExtendedKey: string = extPubPri?.publicExtendedKey;
const privateExtendedKey = extPubPri?.privateExtendedKey;
const privateBytes: Uint8Array =
accountFromExtendedKey(privateExtendedKey).privateKey ||
(() => {
throw new Error("Failed to derive private key");
})();
// No real content is necessary, we just want something signed,
// so we might as well use nostr libs for nostr functions.
// Besides: someday we may create real content that we can relay.
const event: EventTemplate = {
kind: 30402,
tags: [[]],
content: "",
created_at: 0,
};
const signedEvent: VerifiedEvent = finalizeEvent(
// Why does IntelliJ not see matching types?
event as EventTemplate,
privateBytes,
) as VerifiedEvent;
return { signedEvent, publicExtendedKey };
}
/**
* Send project information to external partner service
* Integrates with Nostr-based partner services like Trustroots and TripHopping
* @param linkCode - Service-specific link code identifier
* @param serviceName - Human-readable service name
* @param jwtId - JWT identifier for the project claim
* @param signedPayload - Cryptographically signed payload
* @param publicExtendedKey - Public key for verification
*/
private async sendToNostrPartner(
linkCode: string,
serviceName: string,
jwtId: string,
signedPayload: VerifiedEvent,
publicExtendedKey: string,
) {
try {
let partnerServer = DEFAULT_PARTNER_API_SERVER;
const settings = await this.$accountSettings();
if (settings.partnerApiServer) {
partnerServer = settings.partnerApiServer;
}
const endorserPartnerUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl;
const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey;
const unsignedPayload: UnsignedEvent = {
// why doesn't "...signedPayload" work?
kind: signedPayload.kind,
tags: signedPayload.tags,
content: signedPayload.content,
created_at: signedPayload.created_at,
pubkey: publicKeyHex,
};
// Why does IntelliJ not see matching types?
const payload = serializeEvent(unsignedPayload as UnsignedEvent);
const partnerParams = {
jwtId: jwtId,
linkCode: linkCode,
inputJson: JSON.stringify(content),
pubKeyHex: publicKeyHex,
pubKeyImage: payload,
pubKeySigHex: signedPayload.sig,
};
const headers = await getHeaders(this.activeDid);
const linkResp = await this.axios.post(
endorserPartnerUrl,
partnerParams,
{ headers },
);
if (linkResp.status === 201) {
this.notify.success(
createProjectPartnerSendSuccessMessage(serviceName),
);
} else {
// axios never gets here because it throws an error, but just in case
this.notify.error(
createProjectPartnerSendErrorMessage(
serviceName,
JSON.stringify(linkResp.data),
),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
logger.error(`Error sending to ${serviceName}`, error);
let errorMessage = NOTIFY_PROJECT_PARTNER_SEND_GENERAL_ERROR.message;
if (error.response?.data?.error?.message) {
errorMessage = error.response.data.error.message;
}
this.notify.error(errorMessage, PROJECT_TIMEOUT_VERY_LONG);
}
}
/**
* Handle save project button click
* Manages loading state and initiates project save operation
*/
public async onSaveProjectClick() {
this.isHiddenSave = true;
this.isHiddenSpinner = false;
if (this.numAccounts === 0) {
logger.error("Error: there is no account.");
} else {
this.saveProject();
}
}
/**
* Confirm location marker erasure
* Shows confirmation dialog before clearing location data
*/
confirmEraseLatLong() {
this.notify.confirm(
NOTIFY_PROJECT_DELETE_LOCATION_CONFIRM.message,
async () => {
this.eraseLatLong();
},
);
}
/**
* Clear location data
* Resets latitude, longitude, and location inclusion state
*/
public eraseLatLong() {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
}
/**
* Handle cancel button click
* Returns to previous view without saving changes
*/
public onCancelClick() {
this.$router.back();
}
/**
* Show information about Nostr partner integration
* Displays privacy information about partner service integration
*/
public showNostrPartnerInfo() {
this.notify.info(
NOTIFY_PROJECT_NOSTR_PARTNER_INFO.message,
PROJECT_TIMEOUT_VERY_LONG,
);
}
/**
* Handle map click events
* Updates latitude and longitude based on map click location
* @param event - Leaflet mouse event containing clicked coordinates
*/
onMapClick(event: LeafletMouseEvent) {
this.latitude = event.latlng.lat;
this.longitude = event.latlng.lng;
}
/**
* Computed property for character count display
* Shows current description length and maximum character limit
*/
get descriptionCharacterCount(): string {
const currentLength = this.fullClaim.description?.length || 0;
return `${currentLength}/5000 max. characters`;
}
/**
* Computed property for agent DID validation warning visibility
* Shows warning when changing ownership from original project owner
*/
get shouldShowOwnershipWarning(): boolean {
return (
this.activeDid !== this.projectIssuerDid &&
this.agentDid !== this.projectIssuerDid
);
}
/**
* Computed property for timezone display
* Shows current timezone name with proper formatting
*/
get timezoneDisplay(): string {
return `${this.zoneName} time zone`;
}
/**
* Computed property for map marker visibility
* Determines when to show the location marker on the map
*/
get shouldShowMapMarker(): boolean {
return !!(this.latitude && this.longitude);
}
/**
* Computed property for partner service options visibility
* Shows partner options only when advanced settings are enabled and location is included
*/
get shouldShowPartnerOptions(): boolean {
return this.showGeneralAdvanced && this.includeLocation;
}
/**
* Computed property for save button classes
* Provides consistent styling for the save project button
*/
get saveButtonClasses(): string {
return "block w-full text-center text-lg font-bold 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-2 py-3 rounded-md mb-2";
}
/**
* Computed property for cancel button classes
* Provides consistent styling for the cancel button
*/
get cancelButtonClasses(): string {
return "block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md";
}
/**
* Computed property for camera icon classes
* Provides consistent styling for the camera icon button
*/
get cameraIconClasses(): string {
return "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";
}
/**
* Computed property for image display state
* Determines whether to show image or camera icon
*/
get hasImage(): boolean {
return !!this.imageUrl;
}
/**
* Computed property for save button text visibility
* Controls visibility of "Save Project" text based on loading state
*/
get shouldShowSaveText(): boolean {
return !this.isHiddenSave;
}
/**
* Computed property for spinner visibility
* Controls visibility of saving spinner based on loading state
*/
get shouldShowSpinner(): boolean {
return !this.isHiddenSpinner;
}
}
</script>