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