Compare commits
2 Commits
9ca2068b34
...
a371f11811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a371f11811 | ||
|
|
3f9dd514ce |
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
42
src/utils/share.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
41
src/views/ShareImport.vue
Normal 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>
|
||||
Reference in New Issue
Block a user