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.
706 lines
21 KiB
706 lines
21 KiB
<template>
|
|
<QuickNav />
|
|
<TopMessage />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-6 pb-24">
|
|
<!-- Breadcrumb -->
|
|
<div id="ViewBreadcrumb" class="mb-8">
|
|
<h1 class="text-lg text-center font-light relative px-7">
|
|
<!-- Back -->
|
|
<button
|
|
@click="$router.go(-1)"
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
>
|
|
<fa icon="chevron-left" class="fa-fw"></fa>
|
|
</button>
|
|
Idea
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Project Details -->
|
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
|
<div>
|
|
<div class="block pb-4 flex gap-4">
|
|
<div class="flex-none w-16 pt-1">
|
|
<EntityIcon
|
|
:entityId="projectId"
|
|
:iconSize="64"
|
|
class="block border border-slate-300 rounded-md"
|
|
></EntityIcon>
|
|
</div>
|
|
|
|
<div class="overflow-hidden">
|
|
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
|
<div class="text-sm mb-3">
|
|
<div class="truncate">
|
|
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
|
{{ issuer }}
|
|
</div>
|
|
<div v-if="timeSince">
|
|
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
|
{{ timeSince }}
|
|
</div>
|
|
<div v-if="latitude || longitude">
|
|
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
|
<a
|
|
:href="getOpenStreetMapUrl()"
|
|
target="_blank"
|
|
class="underline"
|
|
>Map View
|
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
|
</a>
|
|
</div>
|
|
<div v-if="url">
|
|
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
|
<a :href="addScheme(url)" target="_blank" class="underline"
|
|
>{{ domainForWebsite(this.url) }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-sm text-slate-500">
|
|
<div v-if="!expanded">
|
|
{{ truncatedDesc }}
|
|
<a
|
|
v-if="description.length >= truncateLength"
|
|
@click="expandText"
|
|
class="uppercase text-xs font-semibold text-slate-700"
|
|
>... Read More</a
|
|
>
|
|
</div>
|
|
<div v-else>
|
|
{{ description }}
|
|
<a
|
|
@click="collapseText"
|
|
class="uppercase text-xs font-semibold text-slate-700"
|
|
>- Read Less</a
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-if="issuer == activeDid"
|
|
type="button"
|
|
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
|
@click="onEditClick()"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="activeDid" class="mb-4">
|
|
<div class="text-center">
|
|
<button
|
|
@click="openOfferDialog({ name: 'you', did: activeDid })"
|
|
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
|
>
|
|
I offer…
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="activeDid">
|
|
<div class="text-center">
|
|
<button
|
|
@click="openGiftDialog({ name: 'you', did: activeDid })"
|
|
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
|
>
|
|
I gave…
|
|
</button>
|
|
<p class="mt-2 mb-4 text-center">Or, record a contribution from:</p>
|
|
</div>
|
|
|
|
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
|
<li @click="openGiftDialog()">
|
|
<EntityIcon
|
|
:entityId="null"
|
|
:iconSize="64"
|
|
class="mx-auto border border-slate-300 rounded-md mb-1"
|
|
></EntityIcon>
|
|
<h3
|
|
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
|
>
|
|
Anonymous/Unnamed
|
|
</h3>
|
|
</li>
|
|
<li
|
|
v-for="contact in allContacts"
|
|
:key="contact.did"
|
|
@click="openGiftDialog(contact)"
|
|
>
|
|
<EntityIcon
|
|
:entityId="contact.did"
|
|
:iconSize="64"
|
|
class="mx-auto border border-slate-300 rounded-md mb-1"
|
|
></EntityIcon>
|
|
<h3
|
|
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
|
>
|
|
{{ contact.name || "(no name)" }}
|
|
</h3>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
|
<router-link
|
|
v-if="allContacts.length >= 7"
|
|
:to="{ name: 'contact-gives' }"
|
|
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
|
>
|
|
Show More Contacts…
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Gifts to & from this -->
|
|
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
|
Offered To This Idea
|
|
</h3>
|
|
|
|
<div v-if="offersToThis.length === 0">
|
|
(None yet. Record one above.)
|
|
</div>
|
|
|
|
<ul v-else class="text-sm border-t border-slate-300">
|
|
<li
|
|
v-for="offer in offersToThis"
|
|
:key="offer.id"
|
|
class="py-1.5 border-b border-slate-300"
|
|
>
|
|
<div class="flex justify-between gap-4">
|
|
<span>
|
|
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
|
{{ didInfo(offer.agentDid, activeDid, allMyDids, allContacts) }}
|
|
</span>
|
|
<a @click="onClickLoadClaim(offer.jwtId)">
|
|
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
|
</a>
|
|
<span v-if="offer.amount">
|
|
<fa
|
|
:icon="iconForUnitCode(offer.unit)"
|
|
class="fa-fw text-slate-400"
|
|
/>{{ offer.amount }}
|
|
</span>
|
|
</div>
|
|
<div v-if="offer.objectDescription" class="text-slate-500">
|
|
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
|
{{ offer.objectDescription }}
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
|
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
|
|
|
|
<div v-if="givesToThis.length === 0">(None yet. Record one above.)</div>
|
|
|
|
<ul v-else class="text-sm border-t border-slate-300">
|
|
<li
|
|
v-for="give in givesToThis"
|
|
:key="give.id"
|
|
class="py-1.5 border-b border-slate-300"
|
|
>
|
|
<div class="flex justify-between gap-4">
|
|
<span
|
|
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
|
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
|
</span>
|
|
<a @click="onClickLoadClaim(give.jwtId)">
|
|
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
|
</a>
|
|
<span v-if="give.amount">
|
|
<fa
|
|
:icon="iconForUnitCode(give.unit)"
|
|
class="fa-fw text-slate-400"
|
|
/>{{ give.amount }}
|
|
</span>
|
|
</div>
|
|
<div v-if="give.description" class="text-slate-500">
|
|
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
|
{{ give.description }}
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="grid items-start grid-cols-1 gap-4">
|
|
<div
|
|
v-if="fulfillersToThis.length > 0"
|
|
class="bg-slate-100 px-4 py-3 rounded-md"
|
|
>
|
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
|
Contributions To This Idea
|
|
</h3>
|
|
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
|
<div class="text-center">
|
|
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
|
|
<button
|
|
@click="onClickLoadProject(plan.handleId)"
|
|
class="text-blue-500"
|
|
>
|
|
{{ plan.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
|
Contributions By This Idea
|
|
</h3>
|
|
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
|
<div class="text-center">
|
|
<button
|
|
@click="onClickLoadProject(fulfilledByThis.handleId)"
|
|
class="text-blue-500"
|
|
>
|
|
{{ fulfilledByThis.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<GiftedDialog
|
|
ref="customGiveDialog"
|
|
message="Received from"
|
|
:projectId="this.projectId"
|
|
>
|
|
</GiftedDialog>
|
|
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
|
|
</OfferDialog>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
|
import * as moment from "moment";
|
|
import { IIdentifier } from "@veramo/core";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
|
|
import GiftedDialog from "@/components/GiftedDialog.vue";
|
|
import OfferDialog from "@/components/OfferDialog.vue";
|
|
import TopMessage from "@/components/TopMessage.vue";
|
|
import { accountsDB, db } from "@/db/index";
|
|
import { Contact } from "@/db/tables/contacts";
|
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
|
import { accessToken } from "@/libs/crypto";
|
|
import { isGlobalUri } from "@/libs/util";
|
|
import {
|
|
didInfo,
|
|
GiverInputInfo,
|
|
GiveServerRecord,
|
|
OfferServerRecord,
|
|
PlanServerRecord,
|
|
} from "@/libs/endorserServer";
|
|
import QuickNav from "@/components/QuickNav.vue";
|
|
import EntityIcon from "@/components/EntityIcon.vue";
|
|
import { Account } from "@/db/tables/accounts";
|
|
|
|
interface Notification {
|
|
group: string;
|
|
type: string;
|
|
title: string;
|
|
text: string;
|
|
}
|
|
|
|
@Component({
|
|
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav, TopMessage },
|
|
})
|
|
export default class ProjectViewView extends Vue {
|
|
$notify!: (notification: Notification, timeout?: number) => void;
|
|
|
|
activeDid = "";
|
|
allMyDids: Array<string> = [];
|
|
allContacts: Array<Contact> = [];
|
|
apiServer = "";
|
|
description = "";
|
|
expanded = false;
|
|
fulfilledByThis: PlanServerRecord | null = null;
|
|
fulfillersToThis: Array<PlanServerRecord> = [];
|
|
givesToThis: Array<GiveServerRecord> = [];
|
|
issuer = "";
|
|
latitude = 0;
|
|
longitude = 0;
|
|
name = "";
|
|
offersToThis: Array<OfferServerRecord> = [];
|
|
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
|
timeSince = "";
|
|
truncatedDesc = "";
|
|
truncateLength = 40;
|
|
url = "";
|
|
|
|
async created() {
|
|
await db.open();
|
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
|
this.activeDid = settings?.activeDid || "";
|
|
this.apiServer = settings?.apiServer || "";
|
|
this.allContacts = await db.contacts.toArray();
|
|
|
|
await accountsDB.open();
|
|
const accounts = accountsDB.accounts;
|
|
const accountsArr = await accounts?.toArray();
|
|
this.allMyDids = accountsArr.map((acc) => acc.did);
|
|
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
|
const identity = JSON.parse(account?.identity || "null");
|
|
|
|
const pathParam = window.location.pathname.substring("/project/".length);
|
|
if (pathParam) {
|
|
this.projectId = decodeURIComponent(pathParam);
|
|
}
|
|
this.LoadProject(this.projectId, identity);
|
|
}
|
|
|
|
public async getIdentity(activeDid: string) {
|
|
await accountsDB.open();
|
|
const account = (await accountsDB.accounts
|
|
.where("did")
|
|
.equals(activeDid)
|
|
.first()) as Account;
|
|
const identity = JSON.parse(account?.identity || "null");
|
|
return identity;
|
|
}
|
|
|
|
public async getHeaders(identity: IIdentifier) {
|
|
const token = await accessToken(identity);
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer " + token,
|
|
};
|
|
return headers;
|
|
}
|
|
|
|
onEditClick() {
|
|
localStorage.setItem("projectId", this.projectId as string);
|
|
const route = {
|
|
name: "new-edit-project",
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
// Isn't there a better way to make this available to the template?
|
|
didInfo(
|
|
did: string,
|
|
activeDid: string,
|
|
dids: Array<string>,
|
|
contacts: Array<Contact>,
|
|
) {
|
|
return didInfo(did, activeDid, dids, contacts);
|
|
}
|
|
|
|
expandText() {
|
|
this.expanded = true;
|
|
}
|
|
|
|
collapseText() {
|
|
this.expanded = false;
|
|
}
|
|
|
|
async LoadProject(projectId: string, identity: IIdentifier) {
|
|
this.projectId = projectId;
|
|
|
|
const url =
|
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
|
const headers: RawAxiosRequestHeaders = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
if (identity) {
|
|
const token = await accessToken(identity);
|
|
headers["Authorization"] = "Bearer " + token;
|
|
}
|
|
|
|
try {
|
|
const resp = await this.axios.get(url, { headers });
|
|
if (resp.status === 200) {
|
|
const startTime = resp.data.startTime;
|
|
if (startTime != null) {
|
|
const eventDate = new Date(startTime);
|
|
const now = moment.now();
|
|
this.timeSince = moment.utc(now).to(eventDate);
|
|
}
|
|
this.issuer = resp.data.issuer;
|
|
this.name = resp.data.claim?.name || "(no name)";
|
|
this.description = resp.data.claim?.description || "(no description)";
|
|
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
|
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
|
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
|
this.url = resp.data.claim?.url || "";
|
|
} else {
|
|
// actually, axios throws an error on 404 so we probably never get here
|
|
console.log("Error getting project:", resp);
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "There was a problem getting that project. See logs for more info.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Error retrieving project:", error);
|
|
const serverError = error as AxiosError;
|
|
if (serverError.response?.status === 404) {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "That project does not exist.",
|
|
},
|
|
-1,
|
|
);
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving that project. See logs for more info.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
}
|
|
|
|
const givesInUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/givesForPlans?planIds=" +
|
|
encodeURIComponent(JSON.stringify([projectId]));
|
|
try {
|
|
const resp = await this.axios.get(givesInUrl, { headers });
|
|
if (resp.status === 200 && resp.data.data) {
|
|
this.givesToThis = resp.data.data;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve gives to this project.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving gives to this project.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Error retrieving gives to this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
|
|
const offersToUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/offersToPlans?planIds=" +
|
|
encodeURIComponent(JSON.stringify([projectId]));
|
|
try {
|
|
const resp = await this.axios.get(offersToUrl, { headers });
|
|
if (resp.status === 200 && resp.data.data) {
|
|
this.offersToThis = resp.data.data;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve offers to this project.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving offers to this project.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Error retrieving offers to this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
|
|
const fulfilledByUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
|
encodeURIComponent(projectId);
|
|
try {
|
|
const resp = await this.axios.get(fulfilledByUrl, { headers });
|
|
if (resp.status === 200) {
|
|
this.fulfilledByThis = resp.data.data;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve plans fulfilled by this project.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving plans fulfilled by this project.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Error retrieving plans fulfilled by this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
|
|
const fulfillersToUrl =
|
|
this.apiServer +
|
|
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
|
encodeURIComponent(projectId);
|
|
try {
|
|
const resp = await this.axios.get(fulfillersToUrl, { headers });
|
|
if (resp.status === 200) {
|
|
this.fulfillersToThis = resp.data.data;
|
|
} else {
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Failed to retrieve plan fulfillers to this project.",
|
|
},
|
|
-1,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const serverError = error as AxiosError;
|
|
this.$notify(
|
|
{
|
|
group: "alert",
|
|
type: "danger",
|
|
title: "Error",
|
|
text: "Something went wrong retrieving plan fulfillers to this project.",
|
|
},
|
|
-1,
|
|
);
|
|
console.error(
|
|
"Error retrieving plan fulfillers to this project:",
|
|
serverError.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle clicking on a project entry found in the list
|
|
* @param id of the project
|
|
**/
|
|
async onClickLoadProject(projectId: string) {
|
|
localStorage.setItem("projectId", projectId);
|
|
const route = {
|
|
path: "/project/" + encodeURIComponent(projectId),
|
|
};
|
|
this.$router.push(route);
|
|
this.LoadProject(projectId, await this.getIdentity(this.activeDid));
|
|
}
|
|
|
|
getOpenStreetMapUrl() {
|
|
// Google URL is https://maps.google.com/?q=LAT,LONG
|
|
return (
|
|
"https://www.openstreetmap.org/?mlat=" +
|
|
this.latitude +
|
|
"&mlon=" +
|
|
this.longitude +
|
|
"#map=15/" +
|
|
this.latitude +
|
|
"/" +
|
|
this.longitude
|
|
);
|
|
}
|
|
|
|
openGiftDialog(contact: GiverInputInfo) {
|
|
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
|
|
}
|
|
|
|
openOfferDialog() {
|
|
(this.$refs.customOfferDialog as OfferDialog).open();
|
|
}
|
|
|
|
onClickLoadClaim(jwtId: string) {
|
|
const route = {
|
|
path: "/claim/" + encodeURIComponent(jwtId),
|
|
};
|
|
this.$router.push(route);
|
|
}
|
|
|
|
UNIT_CODES: Record<string, Record<string, string>> = {
|
|
BTC: {
|
|
name: "Bitcoin",
|
|
faIcon: "bitcoin-sign",
|
|
},
|
|
HUR: {
|
|
name: "hours",
|
|
faIcon: "clock",
|
|
},
|
|
USD: {
|
|
name: "US Dollars",
|
|
faIcon: "dollar",
|
|
},
|
|
};
|
|
|
|
iconForUnitCode(unitCode: string) {
|
|
return this.UNIT_CODES[unitCode]?.faIcon || "question";
|
|
}
|
|
|
|
// return an HTTPS URL if it's not a global URL
|
|
addScheme(url: string) {
|
|
if (!isGlobalUri(url)) {
|
|
return "https://" + url;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
// return just the domain for display, if possible
|
|
domainForWebsite(url: string) {
|
|
try {
|
|
const hostname = new URL(url).hostname;
|
|
if (!hostname) {
|
|
// happens for non-http URLs
|
|
return url;
|
|
} else if (url.endsWith(hostname)) {
|
|
// it's just the domain
|
|
return hostname;
|
|
} else {
|
|
// there's more, but don't bother displaying the whole thing
|
|
return hostname + "...";
|
|
}
|
|
} catch (error: unknown) {
|
|
// must not be a valid URL
|
|
return url;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|