From a371f11811beebd0a802bb30be1e3e12d6e3429b Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 19 Jun 2026 15:43:43 +0200 Subject: [PATCH] feat: share radars via self-contained URL links The app has no backend, so a share link embeds the whole radar as base64url in the query string. Opening /share?d=... validates and imports a fresh copy into the recipient's radars, then opens it. --- src/router.ts | 2 + src/storage.ts | 83 ++++++++++++++++++++++++++++++++++++++- src/utils/share.ts | 42 ++++++++++++++++++++ src/views/RadarEditor.vue | 14 +++++++ src/views/ShareImport.vue | 41 +++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/utils/share.ts create mode 100644 src/views/ShareImport.vue diff --git a/src/router.ts b/src/router.ts index 177d6e7..7f2ae37 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,11 +1,13 @@ import { createRouter, createWebHistory } from "vue-router" import RadarList from "./views/RadarList.vue" import RadarEditor from "./views/RadarEditor.vue" +import ShareImport from "./views/ShareImport.vue" export const router = createRouter({ history: createWebHistory(), routes: [ { path: "/", name: "list", component: RadarList }, { path: "/radar/:id", name: "editor", component: RadarEditor, props: true }, + { path: "/share", name: "share", component: ShareImport }, ], }) diff --git a/src/storage.ts b/src/storage.ts index 8f5b5a1..0e51dc0 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,6 +1,14 @@ import { ref, watch } from "vue" import type { Radar, Criterion, Profile } from "./types.ts" -import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from "./types.ts" +import { + MIN_CRITERIA, + MAX_CRITERIA, + MIN_PROFILES, + MAX_PROFILES, + MIN_SCORE, + MAX_SCORE, + PROFILE_PALETTE, +} from "./types.ts" const STORAGE_KEY = "product-radar:radars" @@ -106,18 +114,91 @@ export function useRadars() { radar.updatedAt = Date.now() } + function duplicate(id: string): Radar | undefined { + const source = get(id) + if (!source) return undefined + const now = Date.now() + const radar: Radar = { + ...JSON.parse(JSON.stringify(source)), + id: uid(), + name: `${source.name || "Untitled radar"} (copy)`, + createdAt: now, + updatedAt: now, + } + radars.value.push(radar) + return radar + } + function remove(id: string): void { const idx = radars.value.findIndex((r) => r.id === id) if (idx >= 0) radars.value.splice(idx, 1) } + // Build a fresh, owned radar from an untrusted payload (e.g. a share link). + // Everything is validated and clamped — a malformed payload yields undefined + // rather than corrupting the store. Inner criterion/profile ids are kept so + // the scores map stays consistent; only the radar id and timestamps are new. + function importRadar(payload: unknown): Radar | undefined { + if (!payload || typeof payload !== "object") return undefined + const p = payload as Partial + + const criteria: Criterion[] = (Array.isArray(p.criteria) ? p.criteria : []) + .filter((c): c is Criterion => !!c && typeof c === "object") + .slice(0, MAX_CRITERIA) + .map((c) => ({ id: typeof c.id === "string" ? c.id : uid(), name: String(c.name ?? "") })) + + const profiles: Profile[] = (Array.isArray(p.profiles) ? p.profiles : []) + .filter((pr): pr is Profile => !!pr && typeof pr === "object") + .slice(0, MAX_PROFILES) + .map((pr, i) => ({ + id: typeof pr.id === "string" ? pr.id : uid(), + name: String(pr.name ?? ""), + color: + typeof pr.color === "string" ? pr.color : PROFILE_PALETTE[i % PROFILE_PALETTE.length], + })) + + if (criteria.length < MIN_CRITERIA || profiles.length < MIN_PROFILES) return undefined + + const rawScores = (p.scores && typeof p.scores === "object" ? p.scores : {}) as Record< + string, + Record + > + const scores: Radar["scores"] = {} + for (const profile of profiles) { + scores[profile.id] = {} + for (const criterion of criteria) { + const v = Number(rawScores[profile.id]?.[criterion.id]) + scores[profile.id][criterion.id] = Number.isFinite(v) + ? Math.min(MAX_SCORE, Math.max(MIN_SCORE, Math.round(v))) + : 0 + } + } + + const now = Date.now() + const radar: Radar = { + id: uid(), + name: String(p.name ?? "Shared radar"), + concept: String(p.concept ?? ""), + criteria, + profiles, + scores, + createdAt: now, + updatedAt: now, + } + ensureScores(radar) + radars.value.push(radar) + return radar + } + return { radars, list, get, createBlank, update, + duplicate, remove, + importRadar, blankCriterion, blankProfile, } diff --git a/src/utils/share.ts b/src/utils/share.ts new file mode 100644 index 0000000..f671714 --- /dev/null +++ b/src/utils/share.ts @@ -0,0 +1,42 @@ +import type { Radar } from "../types.ts" + +/** The slice of a radar that travels in a share link. Volatile fields (id, + * timestamps) are dropped — they get regenerated when the link is imported. */ +export type SharePayload = Pick + +// btoa/atob only handle Latin-1, but names and concepts can be any Unicode, so +// we round-trip through UTF-8 bytes and use the URL-safe base64 alphabet. +function toBase64Url(input: string): string { + const bytes = new TextEncoder().encode(input) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function fromBase64Url(input: string): string { + const base64 = input.replace(/-/g, "+").replace(/_/g, "/") + const binary = atob(base64) + const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)) + return new TextDecoder().decode(bytes) +} + +export function encodeRadar(radar: Radar): string { + const payload: SharePayload = { + name: radar.name, + concept: radar.concept, + criteria: radar.criteria, + profiles: radar.profiles, + scores: radar.scores, + } + return toBase64Url(JSON.stringify(payload)) +} + +export function decodeRadar(encoded: string): SharePayload | null { + try { + const parsed = JSON.parse(fromBase64Url(encoded)) + if (!parsed || typeof parsed !== "object") return null + return parsed as SharePayload + } catch { + return null + } +} diff --git a/src/views/RadarEditor.vue b/src/views/RadarEditor.vue index 20a1105..237cf36 100644 --- a/src/views/RadarEditor.vue +++ b/src/views/RadarEditor.vue @@ -7,6 +7,7 @@ import CriteriaEditor from "../components/CriteriaEditor.vue" import ProfilesEditor from "../components/ProfilesEditor.vue" import ScoreGrid from "../components/ScoreGrid.vue" import { svgToPngBlob, downloadBlob, copyBlobToClipboard, slugify } from "../utils/png" +import { encodeRadar } from "../utils/share" const props = defineProps<{ id: string }>() const router = useRouter() @@ -59,6 +60,18 @@ async function downloadPng(): Promise { } } +async function copyShareLink(): Promise { + if (!radar.value) return + const href = router.resolve({ name: "share", query: { d: encodeRadar(radar.value) } }).href + try { + await navigator.clipboard.writeText(`${location.origin}${href}`) + flash("success", "Share link copied") + } catch (err) { + console.error(err) + flash("error", "Could not copy link") + } +} + async function copyPng(): Promise { const svg = getSvg() if (!svg) return @@ -99,6 +112,7 @@ async function copyPng(): Promise { />
+
diff --git a/src/views/ShareImport.vue b/src/views/ShareImport.vue new file mode 100644 index 0000000..b68bcc3 --- /dev/null +++ b/src/views/ShareImport.vue @@ -0,0 +1,41 @@ + + +