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:
120
src/views/RadarEditor.vue
Normal file
120
src/views/RadarEditor.vue
Normal 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>
|
||||
88
src/views/RadarList.vue
Normal file
88
src/views/RadarList.vue
Normal 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 3–7 Criteria on a 0–5 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>
|
||||
Reference in New Issue
Block a user