You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

952 lines
30 KiB

<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>