Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
a371f11811 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.
2026-06-19 15:43:43 +02:00
Julien Calixte
3f9dd514ce feat: add duplicate button to radar list cards 2026-06-19 15:43:33 +02:00
6 changed files with 185 additions and 2 deletions

View File

@@ -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 },
],
})

View File

@@ -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<Radar>
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<string, unknown>
>
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,
}

42
src/utils/share.ts Normal file
View File

@@ -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<Radar, "name" | "concept" | "criteria" | "profiles" | "scores">
// 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
}
}

View File

@@ -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<void> {
}
}
async function copyShareLink(): Promise<void> {
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<void> {
const svg = getSvg()
if (!svg) return
@@ -99,6 +112,7 @@ async function copyPng(): Promise<void> {
/>
</div>
<div class="flex gap-2 mt-1">
<button class="btn btn-outline" @click="copyShareLink">Share link</button>
<button class="btn btn-outline" @click="copyPng">Copy PNG</button>
<button class="btn btn-primary" @click="downloadPng">Download PNG</button>
</div>

View File

@@ -4,7 +4,7 @@ import { useRouter } from "vue-router"
import { useRadars } from "../storage"
const router = useRouter()
const { radars, createBlank, remove } = useRadars()
const { radars, createBlank, duplicate, remove } = useRadars()
const sorted = computed(() => [...radars.value].sort((a, b) => b.updatedAt - a.updatedAt))
@@ -65,6 +65,9 @@ function formatDate(ts: number): string {
</p>
<p class="text-xs text-base-content/40 mt-1">Updated {{ formatDate(r.updatedAt) }}</p>
<div class="card-actions justify-end mt-2">
<button class="btn btn-ghost btn-xs" @click.stop="duplicate(r.id)">
Duplicate
</button>
<button
class="btn btn-ghost btn-xs text-error"
@click.stop="confirmDelete(r.id, r.name)"

41
src/views/ShareImport.vue Normal file
View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { onMounted, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useRadars } from "../storage"
import { decodeRadar } from "../utils/share"
const route = useRoute()
const router = useRouter()
const { importRadar } = useRadars()
const failed = ref(false)
onMounted(() => {
const raw = route.query.d
const encoded = Array.isArray(raw) ? raw[0] : raw
if (typeof encoded === "string" && encoded.length > 0) {
const payload = decodeRadar(encoded)
const radar = payload ? importRadar(payload) : undefined
if (radar) {
router.replace({ name: "editor", params: { id: radar.id } })
return
}
}
failed.value = true
})
</script>
<template>
<section class="max-w-lg mx-auto px-6 py-16 text-center">
<div v-if="failed" class="card bg-base-100 border border-base-300">
<div class="card-body items-center">
<h1 class="text-xl font-semibold">This share link is invalid</h1>
<p class="text-base-content/60">The link may be incomplete or corrupted.</p>
<button class="btn btn-primary mt-2" @click="router.replace({ name: 'list' })">
Go to my radars
</button>
</div>
</div>
<p v-else class="text-base-content/60">Importing shared radar</p>
</section>
</template>