feat: add product concept subtitle to radars

A Radar gains an optional Concept — a short subtitle naming the product
and its typical performances, rendered under the title and wrapping to a
balanced second line when long. Old radars default to an empty Concept
on load.
This commit is contained in:
Julien Calixte
2026-06-17 18:02:02 +02:00
parent 8e751e77b7
commit f42744b409
21 changed files with 699 additions and 171 deletions

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
</script>
<script setup lang="ts"></script>
<template>
<div class="min-h-screen bg-base-200 flex flex-col">
@@ -8,7 +7,7 @@
<router-link to="/" class="text-xl font-semibold tracking-tight">
product-radar
</router-link>
<span class="text-xs text-base-content/50">browser-local · 05 scale</span>
<span class="text-xs text-base-content/50">05 scale</span>
</div>
</header>
<main class="flex-1">

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Radar } from '../types'
import { MIN_CRITERIA, MAX_CRITERIA } from '../types'
import { useRadars } from '../storage'
import type { Radar } from "../types"
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
import { useRadars } from "../storage"
const props = defineProps<{ radar: Radar }>()
const { update, blankCriterion } = useRadars()
@@ -37,15 +37,10 @@ function renameCriterion(criterionId: string, name: string): void {
</span>
</div>
<p class="text-xs text-base-content/60 mb-3">
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not
<em>Price</em>).
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
</p>
<ul class="flex flex-col gap-2">
<li
v-for="c in radar.criteria"
:key="c.id"
class="flex items-center gap-2"
>
<li v-for="c in radar.criteria" :key="c.id" class="flex items-center gap-2">
<input
type="text"
class="input input-bordered input-sm flex-1"
@@ -58,9 +53,7 @@ function renameCriterion(criterionId: string, name: string): void {
:disabled="radar.criteria.length <= MIN_CRITERIA"
@click="removeCriterion(c.id)"
:title="
radar.criteria.length <= MIN_CRITERIA
? `Minimum ${MIN_CRITERIA} criteria`
: 'Remove'
radar.criteria.length <= MIN_CRITERIA ? `Minimum ${MIN_CRITERIA} criteria` : 'Remove'
"
>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Radar } from '../types'
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from '../types'
import { useRadars } from '../storage'
import type { Radar } from "../types"
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
import { useRadars } from "../storage"
const props = defineProps<{ radar: Radar }>()
const { update, blankProfile } = useRadars()
@@ -71,9 +71,7 @@ function setColor(profileId: string, color: string): void {
:disabled="radar.profiles.length <= MIN_PROFILES"
@click="removeProfile(p.id)"
:title="
radar.profiles.length <= MIN_PROFILES
? `Minimum ${MIN_PROFILES} profile`
: 'Remove'
radar.profiles.length <= MIN_PROFILES ? `Minimum ${MIN_PROFILES} profile` : 'Remove'
"
>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Radar } from '../types'
import { MAX_SCORE } from '../types'
import { computed } from "vue"
import type { Radar } from "../types"
import { MAX_SCORE } from "../types"
const props = withDefaults(
defineProps<{
@@ -14,7 +14,43 @@ const props = withDefaults(
const WIDTH = 720
const HEIGHT = computed(() => (props.withChrome === false ? 600 : 800))
const TITLE_H = computed(() => (props.withChrome === false ? 0 : 60))
const subtitleText = computed(() => props.radar.concept?.trim() ?? "")
const SUBTITLE_SINGLE_LINE_MAX = 64
function wrapToTwoLines(text: string): string[] {
const words = text.split(/\s+/).filter(Boolean)
if (words.length < 2) return [text]
let bestSplit = 1
let bestDiff = Infinity
for (let i = 1; i < words.length; i++) {
const diff = Math.abs(words.slice(0, i).join(" ").length - words.slice(i).join(" ").length)
if (diff < bestDiff) {
bestDiff = diff
bestSplit = i
}
}
return [words.slice(0, bestSplit).join(" "), words.slice(bestSplit).join(" ")]
}
// 0 lines = no concept, 1 = fits, 2 = wrapped.
const subtitleLines = computed<string[]>(() => {
const text = subtitleText.value
if (props.withChrome === false || !text) return []
return text.length <= SUBTITLE_SINGLE_LINE_MAX ? [text] : wrapToTwoLines(text)
})
const TITLE_H = computed(() => {
if (props.withChrome === false) return 0
const n = subtitleLines.value.length
return n === 0 ? 60 : n === 1 ? 92 : 116
})
const titleY = computed(() => (subtitleLines.value.length === 0 ? TITLE_H.value / 2 + 4 : 32))
const subtitleFontSize = computed(() => (subtitleLines.value.length > 1 ? 14 : 16))
const subtitleStartY = computed(() => (subtitleLines.value.length > 1 ? 56 : 64))
const subtitleLineHeight = computed(() => (subtitleLines.value.length > 1 ? 18 : 0))
const LEGEND_H = computed(() => (props.withChrome === false ? 0 : 120))
const CHART_TOP = computed(() => TITLE_H.value)
const CHART_BOTTOM = computed(() => HEIGHT.value - LEGEND_H.value)
@@ -47,7 +83,7 @@ const ringPaths = computed<string[]>(() => {
if (total < 3) return []
return [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => {
const pts = Array.from({ length: total }, (_, i) => pointOnAxis(i, total, ratio))
return pts.map((p, idx) => `${idx === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
return pts.map((p, idx) => `${idx === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ") + " Z"
})
})
@@ -67,13 +103,12 @@ const profileShapes = computed<ProfileShape[]>(() => {
const score = props.radar.scores[profile.id]?.[criterion.id] ?? 0
return pointOnAxis(i, total, score / MAX_SCORE)
})
const path =
vertices.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
const path = vertices.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ") + " Z"
return { id: profile.id, name: profile.name, color: profile.color, path, vertices }
})
})
type Label = { x: number; y: number; anchor: 'start' | 'middle' | 'end'; text: string; dy: string }
type Label = { x: number; y: number; anchor: "start" | "middle" | "end"; text: string; dy: string }
const criterionLabels = computed<Label[]>(() => {
const total = props.radar.criteria.length
@@ -83,14 +118,14 @@ const criterionLabels = computed<Label[]>(() => {
const x = CHART_CX + Math.cos(angle) * labelRadius
const y = CHART_CY.value + Math.sin(angle) * labelRadius
const cosA = Math.cos(angle)
let anchor: 'start' | 'middle' | 'end' = 'middle'
if (cosA > 0.2) anchor = 'start'
else if (cosA < -0.2) anchor = 'end'
let anchor: "start" | "middle" | "end" = "middle"
if (cosA > 0.2) anchor = "start"
else if (cosA < -0.2) anchor = "end"
const sinA = Math.sin(angle)
let dy = '0.35em'
if (sinA < -0.5) dy = '0em'
else if (sinA > 0.5) dy = '0.7em'
return { x, y, anchor, text: criterion.name || 'Untitled', dy }
let dy = "0.35em"
if (sinA < -0.5) dy = "0em"
else if (sinA > 0.5) dy = "0.7em"
return { x, y, anchor, text: criterion.name || "Untitled", dy }
})
})
@@ -100,15 +135,19 @@ const scoreRingLabels = computed<Label[]>(() => {
const ratio = score / MAX_SCORE
const x = CHART_CX + 4
const y = CHART_CY.value - CHART_R.value * ratio
return { x, y, anchor: 'start' as const, text: String(score), dy: '0.35em' }
return { x, y, anchor: "start" as const, text: String(score), dy: "0.35em" }
})
})
const titleText = computed(() => props.radar.name || 'Untitled radar')
const titleText = computed(() => props.radar.name || "Untitled radar")
const legendItems = computed(() => {
// up to 5 profiles — fit in one row if possible, else two rows
return props.radar.profiles.map((p) => ({ id: p.id, name: p.name || 'Untitled', color: p.color }))
return props.radar.profiles.map((p) => ({
id: p.id,
name: p.name || "Untitled",
color: p.color,
}))
})
const legendLayout = computed(() => {
@@ -132,7 +171,7 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
role="img"
:aria-label="`Radar chart: ${titleText}`"
class="w-full h-auto"
style="font-family: 'Fredoka', ui-sans-serif, system-ui, sans-serif"
style="font-family: &quot;Fredoka&quot;, ui-sans-serif, system-ui, sans-serif"
>
<rect :width="WIDTH" :height="HEIGHT" fill="#ffffff" />
@@ -140,7 +179,7 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
<text
v-if="withChrome !== false"
:x="WIDTH / 2"
:y="TITLE_H / 2 + 4"
:y="titleY"
text-anchor="middle"
dominant-baseline="middle"
font-size="28"
@@ -150,6 +189,20 @@ const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
{{ titleText }}
</text>
<!-- Concept (subtitle, wraps to two lines when long) -->
<text
v-for="(line, i) in subtitleLines"
:key="`subtitle-${i}`"
:x="WIDTH / 2"
:y="subtitleStartY + i * subtitleLineHeight"
text-anchor="middle"
dominant-baseline="middle"
:font-size="subtitleFontSize"
fill="#64748b"
>
{{ line }}
</text>
<!-- Empty state -->
<text
v-if="insufficientCriteria"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Radar } from '../types'
import { MIN_SCORE, MAX_SCORE } from '../types'
import { useRadars } from '../storage'
import type { Radar } from "../types"
import { MIN_SCORE, MAX_SCORE } from "../types"
import { useRadars } from "../storage"
const props = defineProps<{ radar: Radar }>()
const { update } = useRadars()
@@ -24,16 +24,14 @@ function setScore(profileId: string, criterionId: string, value: number): void {
<template>
<section>
<h2 class="text-lg font-semibold mb-2">Scores</h2>
<p class="text-xs text-base-content/60 mb-3">
0 = worst on this Criterion, 5 = best.
</p>
<p class="text-xs text-base-content/60 mb-3">0 = worst on this Criterion, 5 = best.</p>
<div class="overflow-x-auto rounded-lg border border-base-300 bg-base-100">
<table class="table table-sm">
<thead>
<tr>
<th class="text-left">Profile \ Criterion</th>
<th v-for="c in radar.criteria" :key="c.id" class="text-left">
{{ c.name || 'Untitled' }}
{{ c.name || "Untitled" }}
</th>
</tr>
</thead>
@@ -45,7 +43,7 @@ function setScore(profileId: string, criterionId: string, value: number): void {
:style="{ background: p.color }"
aria-hidden="true"
/>
{{ p.name || 'Untitled' }}
{{ p.name || "Untitled" }}
</td>
<td v-for="c in radar.criteria" :key="c.id">
<div class="flex gap-1">
@@ -54,9 +52,7 @@ function setScore(profileId: string, criterionId: string, value: number): void {
:key="value"
type="button"
class="btn btn-xs"
:class="
getScore(p.id, c.id) === value ? 'btn-primary' : 'btn-ghost'
"
:class="getScore(p.id, c.id) === value ? 'btn-primary' : 'btn-ghost'"
@click="setScore(p.id, c.id, value)"
:aria-label="`Score ${value} for ${p.name} on ${c.name}`"
>

View File

@@ -1,6 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router'
import { createApp } from "vue"
import "./style.css"
import App from "./App.vue"
import { router } from "./router.ts"
createApp(App).use(router).mount('#app')
createApp(App).use(router).mount("#app")

View File

@@ -1,11 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import RadarList from './views/RadarList.vue'
import RadarEditor from './views/RadarEditor.vue'
import { createRouter, createWebHistory } from "vue-router"
import RadarList from "./views/RadarList.vue"
import RadarEditor from "./views/RadarEditor.vue"
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'list', component: RadarList },
{ path: '/radar/:id', name: 'editor', component: RadarEditor, props: true },
{ path: "/", name: "list", component: RadarList },
{ path: "/radar/:id", name: "editor", component: RadarEditor, props: true },
],
})

View File

@@ -1,15 +1,17 @@
import { ref, watch } from 'vue'
import type { Radar, Criterion, Profile } from './types'
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from './types'
import { ref, watch } from "vue"
import type { Radar, Criterion, Profile } from "./types.ts"
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from "./types.ts"
const STORAGE_KEY = 'product-radar:radars'
const STORAGE_KEY = "product-radar:radars"
function loadAll(): Radar[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
if (!Array.isArray(parsed)) return []
// Default concept for radars saved before the field existed.
return parsed.map((r: Radar) => ({ ...r, concept: r.concept ?? "" }))
} catch {
return []
}
@@ -29,12 +31,16 @@ function uid(): string {
return crypto.randomUUID()
}
function blankCriterion(name = ''): Criterion {
function blankCriterion(name = ""): Criterion {
return { id: uid(), name }
}
function blankProfile(name = '', colorIndex = 0): Profile {
return { id: uid(), name, color: PROFILE_PALETTE[colorIndex % PROFILE_PALETTE.length] }
function blankProfile(name = "", colorIndex = 0): Profile {
return {
id: uid(),
name,
color: PROFILE_PALETTE[colorIndex % PROFILE_PALETTE.length],
}
}
function ensureScores(radar: Radar): void {
@@ -69,7 +75,7 @@ export function useRadars() {
return radars.value.find((r) => r.id === id)
}
function createBlank(name = 'Untitled radar'): Radar {
function createBlank(name = "Untitled radar"): Radar {
const criteria: Criterion[] = []
for (let i = 0; i < MIN_CRITERIA; i++) criteria.push(blankCriterion(`Criterion ${i + 1}`))
@@ -80,6 +86,7 @@ export function useRadars() {
const radar: Radar = {
id: uid(),
name,
concept: "",
criteria,
profiles,
scores: {},

View File

@@ -1,6 +1,8 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
themes:
light --default,
dark --prefersdark;
}
@plugin "daisyui/theme" {

View File

@@ -12,6 +12,8 @@ export type Profile = {
export type Radar = {
id: string
name: string
/** Short subtitle naming the product and its typical performances. May be empty. */
concept: string
criteria: Criterion[]
profiles: Profile[]
/** scores[profileId][criterionId] = 0..5 */
@@ -28,12 +30,12 @@ export const MIN_SCORE = 0
export const MAX_SCORE = 5
export const PROFILE_PALETTE = [
'#e11d48', // rose-600
'#2563eb', // blue-600
'#059669', // emerald-600
'#d97706', // amber-600
'#7c3aed', // violet-600
'#0891b2', // cyan-600
'#db2777', // pink-600
'#65a30d', // lime-600
"#e11d48", // rose-600
"#2563eb", // blue-600
"#059669", // emerald-600
"#d97706", // amber-600
"#7c3aed", // violet-600
"#0891b2", // cyan-600
"#db2777", // pink-600
"#65a30d", // lime-600
] as const

View File

@@ -5,14 +5,14 @@ export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promis
source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"')
}
const svgBlob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' })
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" })
const svgUrl = URL.createObjectURL(svgBlob)
try {
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = (e) => reject(e instanceof Event ? new Error('SVG image failed to load') : e)
img.onerror = (e) => reject(e instanceof Event ? new Error("SVG image failed to load") : e)
img.src = svgUrl
})
@@ -20,20 +20,20 @@ export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promis
const w = vb.width || svgElement.clientWidth || 720
const h = vb.height || svgElement.clientHeight || 800
const canvas = document.createElement('canvas')
const canvas = document.createElement("canvas")
canvas.width = Math.round(w * scale)
canvas.height = Math.round(h * scale)
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
ctx.fillStyle = '#ffffff'
const ctx = canvas.getContext("2d")
if (!ctx) throw new Error("Could not get 2D context")
ctx.fillStyle = "#ffffff"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => {
if (b) resolve(b)
else reject(new Error('canvas.toBlob returned null'))
}, 'image/png')
else reject(new Error("canvas.toBlob returned null"))
}, "image/png")
})
} finally {
URL.revokeObjectURL(svgUrl)
@@ -42,7 +42,7 @@ export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promis
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
@@ -54,7 +54,7 @@ export function downloadBlob(blob: Blob, filename: string): void {
export async function copyBlobToClipboard(blob: Blob): Promise<void> {
const ClipItem = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
if (!navigator.clipboard || !ClipItem) {
throw new Error('Clipboard image copy not supported in this browser')
throw new Error("Clipboard image copy not supported in this browser")
}
await navigator.clipboard.write([new ClipItem({ [blob.type]: blob })])
}
@@ -63,10 +63,10 @@ export function slugify(input: string): string {
return (
input
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'radar'
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "radar"
)
}

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { useRadars } from '../storage'
import RadarChart from '../components/RadarChart.vue'
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 { computed, ref, watchEffect } from "vue"
import { useRouter } from "vue-router"
import { useRadars } from "../storage"
import RadarChart from "../components/RadarChart.vue"
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"
const props = defineProps<{ id: string }>()
const router = useRouter()
@@ -16,7 +16,7 @@ const radar = computed(() => get(props.id))
watchEffect(() => {
if (!radar.value) {
router.replace({ name: 'list' })
router.replace({ name: "list" })
}
})
@@ -26,10 +26,16 @@ function setName(name: string): void {
})
}
const chartContainer = ref<HTMLElement | null>(null)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
function setConcept(concept: string): void {
update(props.id, (r) => {
r.concept = concept
})
}
function flash(kind: 'success' | 'error', text: string, ms = 2200): void {
const chartContainer = ref<HTMLElement | null>(null)
const toast = ref<{ kind: "success" | "error"; text: string } | null>(null)
function flash(kind: "success" | "error", text: string, ms = 2200): void {
toast.value = { kind, text }
setTimeout(() => {
toast.value = null
@@ -37,7 +43,7 @@ function flash(kind: 'success' | 'error', text: string, ms = 2200): void {
}
function getSvg(): SVGSVGElement | null {
return chartContainer.value?.querySelector('svg') ?? null
return chartContainer.value?.querySelector("svg") ?? null
}
async function downloadPng(): Promise<void> {
@@ -46,10 +52,10 @@ async function downloadPng(): Promise<void> {
try {
const blob = await svgToPngBlob(svg, 2)
downloadBlob(blob, `${slugify(radar.value.name)}.png`)
flash('success', 'PNG downloaded')
flash("success", "PNG downloaded")
} catch (err) {
console.error(err)
flash('error', 'Could not export PNG')
flash("error", "Could not export PNG")
}
}
@@ -59,10 +65,10 @@ async function copyPng(): Promise<void> {
try {
const blob = await svgToPngBlob(svg, 2)
await copyBlobToClipboard(blob)
flash('success', 'Copied to clipboard')
flash("success", "Copied to clipboard")
} catch (err) {
console.error(err)
flash('error', 'Clipboard copy not supported here — try Download')
flash("error", "Clipboard copy not supported here — try Download")
}
}
</script>
@@ -70,19 +76,29 @@ async function copyPng(): Promise<void> {
<template>
<section v-if="radar" class="max-w-6xl mx-auto px-6 py-8">
<!-- Top bar -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<button class="btn btn-ghost btn-sm" @click="router.push({ name: 'list' })">
<div class="flex flex-wrap items-start gap-3 mb-6">
<button class="btn btn-ghost btn-sm mt-1" @click="router.push({ name: 'list' })">
All radars
</button>
<input
type="text"
class="input input-bordered input-lg flex-1 min-w-[200px] font-semibold"
:value="radar.name"
@input="setName(($event.target as HTMLInputElement).value)"
placeholder="Untitled radar"
aria-label="Radar name"
/>
<div class="flex gap-2">
<div class="flex-1 min-w-[200px] flex flex-col gap-2">
<input
type="text"
class="input input-bordered input-lg font-semibold"
:value="radar.name"
@input="setName(($event.target as HTMLInputElement).value)"
placeholder="Untitled radar"
aria-label="Radar name"
/>
<input
type="text"
class="input input-bordered input-sm"
:value="radar.concept"
@input="setConcept(($event.target as HTMLInputElement).value)"
placeholder="Concept — name the product and its typical performances"
aria-label="Radar concept"
/>
</div>
<div class="flex gap-2 mt-1">
<button class="btn btn-outline" @click="copyPng">Copy PNG</button>
<button class="btn btn-primary" @click="downloadPng">Download PNG</button>
</div>
@@ -90,10 +106,7 @@ async function copyPng(): Promise<void> {
<!-- Toast -->
<div v-if="toast" class="mb-4">
<div
class="alert"
:class="toast.kind === 'success' ? 'alert-success' : 'alert-error'"
>
<div class="alert" :class="toast.kind === 'success' ? 'alert-success' : 'alert-error'">
<span>{{ toast.text }}</span>
</div>
</div>

View File

@@ -1,22 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRadars } from '../storage'
import { computed } from "vue"
import { useRouter } from "vue-router"
import { useRadars } from "../storage"
const router = useRouter()
const { radars, createBlank, remove } = useRadars()
const sorted = computed(() =>
[...radars.value].sort((a, b) => b.updatedAt - a.updatedAt),
)
const sorted = computed(() => [...radars.value].sort((a, b) => b.updatedAt - a.updatedAt))
function createAndOpen(): void {
const radar = createBlank()
router.push({ name: 'editor', params: { id: radar.id } })
router.push({ name: "editor", params: { id: radar.id } })
}
function open(id: string): void {
router.push({ name: 'editor', params: { id } })
router.push({ name: "editor", params: { id } })
}
function confirmDelete(id: string, name: string): void {
@@ -25,9 +23,9 @@ function confirmDelete(id: string, name: string): void {
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
year: "numeric",
month: "short",
day: "numeric",
})
}
</script>
@@ -44,15 +42,10 @@ function formatDate(ts: number): string {
<button class="btn btn-primary" @click="createAndOpen">+ New radar</button>
</div>
<div
v-if="sorted.length === 0"
class="card bg-base-100 border border-base-300 border-dashed"
>
<div v-if="sorted.length === 0" class="card bg-base-100 border border-base-300 border-dashed">
<div class="card-body items-center text-center py-16">
<p class="text-base-content/70">No radars yet.</p>
<button class="btn btn-primary mt-3" @click="createAndOpen">
Create your first radar
</button>
<button class="btn btn-primary mt-3" @click="createAndOpen">Create your first radar</button>
</div>
</div>
@@ -64,15 +57,13 @@ function formatDate(ts: number): string {
@click="open(r.id)"
>
<div class="card-body">
<h2 class="card-title text-lg truncate">{{ r.name || 'Untitled radar' }}</h2>
<h2 class="card-title text-lg truncate">{{ r.name || "Untitled radar" }}</h2>
<p class="text-sm text-base-content/60">
{{ r.criteria.length }} criteria · {{ r.profiles.length }} profile{{
r.profiles.length === 1 ? '' : 's'
r.profiles.length === 1 ? "" : "s"
}}
</p>
<p class="text-xs text-base-content/40 mt-1">
Updated {{ formatDate(r.updatedAt) }}
</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 text-error"