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

88
src/views/RadarList.vue Normal file
View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
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),
)
function createAndOpen(): void {
const radar = createBlank()
router.push({ name: 'editor', params: { id: radar.id } })
}
function open(id: string): void {
router.push({ name: 'editor', params: { id } })
}
function confirmDelete(id: string, name: string): void {
if (confirm(`Delete "${name}"? This cannot be undone.`)) remove(id)
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</script>
<template>
<section class="max-w-5xl mx-auto px-6 py-10">
<div class="flex items-end justify-between mb-8">
<div>
<h1 class="text-3xl font-semibold">Your radars</h1>
<p class="text-base-content/60 mt-1">
Compare up to 5 Profiles across 37 Criteria on a 05 scale.
</p>
</div>
<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 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>
</div>
</div>
<div v-else class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="r in sorted"
:key="r.id"
class="card bg-base-100 shadow-sm hover:shadow-md transition cursor-pointer border border-base-300"
@click="open(r.id)"
>
<div class="card-body">
<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'
}}
</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"
@click.stop="confirmDelete(r.id, r.name)"
>
Delete
</button>
</div>
</div>
</div>
</div>
</section>
</template>