feat: implement radar app — list, editor, chart, scoring, PNG export

- vue-router with list (/) and editor (/radar/:id) views
- localStorage persistence via useRadars() composable
- types.ts: Radar / Criterion / Profile / Score with cardinality constants
- RadarChart.vue: SVG renderer (rings, axes, profile polygons, labels, legend)
- CriteriaEditor / ProfilesEditor / ScoreGrid components
- PNG export: SVG → canvas → blob, download + clipboard copy
- Remove 'coming soon' placeholder from App.vue
This commit is contained in:
Julien Calixte
2026-06-16 22:06:11 +02:00
parent a8b83b79ce
commit 636fba170f
14 changed files with 1294 additions and 22 deletions

120
src/views/RadarEditor.vue Normal file
View File

@@ -0,0 +1,120 @@
<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'
const props = defineProps<{ id: string }>()
const router = useRouter()
const { get, update } = useRadars()
const radar = computed(() => get(props.id))
watchEffect(() => {
if (!radar.value) {
router.replace({ name: 'list' })
}
})
function setName(name: string): void {
update(props.id, (r) => {
r.name = name
})
}
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
}, ms)
}
function getSvg(): SVGSVGElement | null {
return chartContainer.value?.querySelector('svg') ?? null
}
async function downloadPng(): Promise<void> {
const svg = getSvg()
if (!svg || !radar.value) return
try {
const blob = await svgToPngBlob(svg, 2)
downloadBlob(blob, `${slugify(radar.value.name)}.png`)
flash('success', 'PNG downloaded')
} catch (err) {
console.error(err)
flash('error', 'Could not export PNG')
}
}
async function copyPng(): Promise<void> {
const svg = getSvg()
if (!svg) return
try {
const blob = await svgToPngBlob(svg, 2)
await copyBlobToClipboard(blob)
flash('success', 'Copied to clipboard')
} catch (err) {
console.error(err)
flash('error', 'Clipboard copy not supported here — try Download')
}
}
</script>
<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' })">
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">
<button class="btn btn-outline" @click="copyPng">Copy PNG</button>
<button class="btn btn-primary" @click="downloadPng">Download PNG</button>
</div>
</div>
<!-- Toast -->
<div v-if="toast" class="mb-4">
<div
class="alert"
:class="toast.kind === 'success' ? 'alert-success' : 'alert-error'"
>
<span>{{ toast.text }}</span>
</div>
</div>
<!-- Layout: chart left, editors right -->
<div class="grid lg:grid-cols-[1fr_360px] gap-6">
<div
ref="chartContainer"
class="card bg-base-100 border border-base-300 shadow-sm p-4 flex items-center justify-center"
>
<RadarChart :radar="radar" />
</div>
<div class="flex flex-col gap-6">
<CriteriaEditor :radar="radar" />
<ProfilesEditor :radar="radar" />
</div>
</div>
<!-- Score grid below, full width -->
<div class="mt-8">
<ScoreGrid :radar="radar" />
</div>
</section>
</template>