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:
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.23",
|
||||
"vue": "^3.5.34"
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
332
pnpm-lock.yaml
generated
332
pnpm-lock.yaml
generated
@@ -14,16 +14,19 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.34
|
||||
version: 3.5.38(typescript@6.0.3)
|
||||
vue-router:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(@vue/compiler-sfc@3.5.38)(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))
|
||||
version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))
|
||||
'@types/node':
|
||||
specifier: ^24.12.3
|
||||
version: 24.13.2
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.6
|
||||
version: 6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))
|
||||
version: 6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))
|
||||
'@vue/tsconfig':
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||
@@ -35,30 +38,51 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.12
|
||||
version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vue-tsc:
|
||||
specifier: ^3.2.8
|
||||
version: 3.3.5(typescript@6.0.3)
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/generator@8.0.0-rc.6':
|
||||
resolution: {integrity: sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@8.0.0-rc.6':
|
||||
resolution: {integrity: sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@8.0.0-rc.6':
|
||||
resolution: {integrity: sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@8.0.0-rc.6':
|
||||
resolution: {integrity: sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@8.0.0-rc.6':
|
||||
resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -288,6 +312,9 @@ packages:
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
'@types/jsesc@2.5.1':
|
||||
resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==}
|
||||
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
|
||||
@@ -307,6 +334,15 @@ packages:
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
|
||||
'@vue-macros/common@3.1.2':
|
||||
resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.2.25
|
||||
peerDependenciesMeta:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vue/compiler-core@3.5.38':
|
||||
resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==}
|
||||
|
||||
@@ -319,6 +355,15 @@ packages:
|
||||
'@vue/compiler-ssr@3.5.38':
|
||||
resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==}
|
||||
|
||||
'@vue/devtools-api@8.1.3':
|
||||
resolution: {integrity: sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==}
|
||||
|
||||
'@vue/devtools-kit@8.1.3':
|
||||
resolution: {integrity: sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA==}
|
||||
|
||||
'@vue/devtools-shared@8.1.3':
|
||||
resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==}
|
||||
|
||||
'@vue/language-core@3.3.5':
|
||||
resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==}
|
||||
|
||||
@@ -350,9 +395,35 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
acorn@8.17.0:
|
||||
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
alien-signals@3.2.1:
|
||||
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
ast-walker-scope@0.9.0:
|
||||
resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
confbox@0.2.4:
|
||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -374,6 +445,9 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
exsolve@1.0.8:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -391,10 +465,23 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -469,9 +556,20 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
local-pkg@1.2.1:
|
||||
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mlly@1.8.2:
|
||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
@@ -483,6 +581,12 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
perfect-debounce@2.1.0:
|
||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -490,15 +594,31 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
postcss@8.5.15:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
readdirp@5.0.0:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
rolldown@1.0.3:
|
||||
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
scule@1.3.0:
|
||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -522,9 +642,20 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ufo@1.6.4:
|
||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
unplugin-utils@0.3.1:
|
||||
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
unplugin@3.0.0:
|
||||
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
vite@8.0.16:
|
||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -571,6 +702,24 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-router@5.1.0:
|
||||
resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==}
|
||||
peerDependencies:
|
||||
'@pinia/colada': '>=0.21.2'
|
||||
'@vue/compiler-sfc': ^3.5.34
|
||||
pinia: ^3.0.4
|
||||
vite: ^7.0.0 || ^8.0.0
|
||||
vue: ^3.5.34
|
||||
peerDependenciesMeta:
|
||||
'@pinia/colada':
|
||||
optional: true
|
||||
'@vue/compiler-sfc':
|
||||
optional: true
|
||||
pinia:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vue-tsc@3.3.5:
|
||||
resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==}
|
||||
hasBin: true
|
||||
@@ -585,21 +734,51 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/generator@8.0.0-rc.6':
|
||||
dependencies:
|
||||
'@babel/parser': 8.0.0-rc.6
|
||||
'@babel/types': 8.0.0-rc.6
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@types/jsesc': 2.5.1
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-string-parser@8.0.0-rc.6': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@8.0.0-rc.6': {}
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/parser@8.0.0-rc.6':
|
||||
dependencies:
|
||||
'@babel/types': 8.0.0-rc.6
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@babel/types@8.0.0-rc.6':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 8.0.0-rc.6
|
||||
'@babel/helper-validator-identifier': 8.0.0-rc.6
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -756,26 +935,28 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.1
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.1
|
||||
|
||||
'@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))':
|
||||
'@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.1
|
||||
'@tailwindcss/oxide': 4.3.1
|
||||
tailwindcss: 4.3.1
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/jsesc@2.5.1': {}
|
||||
|
||||
'@types/node@24.13.2':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))':
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
@@ -790,6 +971,16 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue-macros/common@3.1.2(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@vue/compiler-sfc': 3.5.38
|
||||
ast-kit: 2.2.0
|
||||
local-pkg: 1.2.1
|
||||
magic-string-ast: 1.0.3
|
||||
unplugin-utils: 0.3.1
|
||||
optionalDependencies:
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
'@vue/compiler-core@3.5.38':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
@@ -820,6 +1011,19 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.38
|
||||
'@vue/shared': 3.5.38
|
||||
|
||||
'@vue/devtools-api@8.1.3':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.1.3
|
||||
|
||||
'@vue/devtools-kit@8.1.3':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 8.1.3
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
perfect-debounce: 2.1.0
|
||||
|
||||
'@vue/devtools-shared@8.1.3': {}
|
||||
|
||||
'@vue/language-core@3.3.5':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
@@ -859,8 +1063,31 @@ snapshots:
|
||||
typescript: 6.0.3
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
acorn@8.17.0: {}
|
||||
|
||||
alien-signals@3.2.1: {}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
pathe: 2.0.3
|
||||
|
||||
ast-walker-scope@0.9.0:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
ast-kit: 2.2.0
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
confbox@0.2.4: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
daisyui@5.5.23: {}
|
||||
@@ -876,6 +1103,8 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -885,8 +1114,14 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -936,26 +1171,63 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
local-pkg@1.2.1:
|
||||
dependencies:
|
||||
mlly: 1.8.2
|
||||
pkg-types: 2.3.1
|
||||
quansync: 0.2.11
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mlly@1.8.2:
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
pathe: 2.0.3
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.4
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
mlly: 1.8.2
|
||||
pathe: 2.0.3
|
||||
|
||||
pkg-types@2.3.1:
|
||||
dependencies:
|
||||
confbox: 0.2.4
|
||||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
postcss@8.5.15:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
rolldown@1.0.3:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.133.0
|
||||
@@ -977,6 +1249,8 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.3
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.3
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
tailwindcss@4.3.1: {}
|
||||
@@ -993,9 +1267,22 @@ snapshots:
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
ufo@1.6.4: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0):
|
||||
unplugin-utils@0.3.1:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
|
||||
unplugin@3.0.0:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
picomatch: 4.0.4
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -1006,9 +1293,34 @@ snapshots:
|
||||
'@types/node': 24.13.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
yaml: 2.9.0
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0-rc.6
|
||||
'@vue-macros/common': 3.1.2(vue@3.5.38(typescript@6.0.3))
|
||||
'@vue/devtools-api': 8.1.3
|
||||
ast-walker-scope: 0.9.0
|
||||
chokidar: 5.0.0
|
||||
json5: 2.2.3
|
||||
local-pkg: 1.2.1
|
||||
magic-string: 0.30.21
|
||||
mlly: 1.8.2
|
||||
muggle-string: 0.4.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
scule: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
unplugin: 3.0.0
|
||||
unplugin-utils: 0.3.1
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.38
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)
|
||||
|
||||
vue-tsc@3.3.5(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
@@ -1024,3 +1336,7 @@ snapshots:
|
||||
'@vue/shared': 3.5.38
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
22
src/App.vue
22
src/App.vue
@@ -2,17 +2,17 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen flex items-center justify-center bg-base-200 p-6">
|
||||
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div class="card-body items-center text-center">
|
||||
<h1 class="card-title text-2xl">product-radar</h1>
|
||||
<p class="text-base-content/70">
|
||||
Compare up to 5 Profiles across 3–7 Criteria on a 0–5 scale.
|
||||
</p>
|
||||
<div class="card-actions mt-4">
|
||||
<button class="btn btn-primary" disabled>New Radar — coming soon</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-screen bg-base-200 flex flex-col">
|
||||
<header class="bg-base-100 border-b border-base-300">
|
||||
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<router-link to="/" class="text-xl font-semibold tracking-tight">
|
||||
product-radar
|
||||
</router-link>
|
||||
<span class="text-xs text-base-content/50">browser-local · 0–5 scale</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
78
src/components/CriteriaEditor.vue
Normal file
78
src/components/CriteriaEditor.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
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()
|
||||
|
||||
function addCriterion(): void {
|
||||
update(props.radar.id, (r) => {
|
||||
if (r.criteria.length >= MAX_CRITERIA) return
|
||||
r.criteria.push(blankCriterion(`Criterion ${r.criteria.length + 1}`))
|
||||
})
|
||||
}
|
||||
|
||||
function removeCriterion(criterionId: string): void {
|
||||
update(props.radar.id, (r) => {
|
||||
if (r.criteria.length <= MIN_CRITERIA) return
|
||||
r.criteria = r.criteria.filter((c) => c.id !== criterionId)
|
||||
})
|
||||
}
|
||||
|
||||
function renameCriterion(criterionId: string, name: string): void {
|
||||
update(props.radar.id, (r) => {
|
||||
const c = r.criteria.find((x) => x.id === criterionId)
|
||||
if (c) c.name = name
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">Criteria</h2>
|
||||
<span class="text-xs text-base-content/50">
|
||||
{{ radar.criteria.length }} / {{ MAX_CRITERIA }} · min {{ MIN_CRITERIA }}
|
||||
</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>).
|
||||
</p>
|
||||
<ul class="flex flex-col 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"
|
||||
:value="c.name"
|
||||
@input="renameCriterion(c.id, ($event.target as HTMLInputElement).value)"
|
||||
placeholder="Criterion name"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
:disabled="radar.criteria.length <= MIN_CRITERIA"
|
||||
@click="removeCriterion(c.id)"
|
||||
:title="
|
||||
radar.criteria.length <= MIN_CRITERIA
|
||||
? `Minimum ${MIN_CRITERIA} criteria`
|
||||
: 'Remove'
|
||||
"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
class="btn btn-outline btn-sm mt-3"
|
||||
:disabled="radar.criteria.length >= MAX_CRITERIA"
|
||||
@click="addCriterion"
|
||||
>
|
||||
+ Add criterion
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
108
src/components/ProfilesEditor.vue
Normal file
108
src/components/ProfilesEditor.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
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()
|
||||
|
||||
function addProfile(): void {
|
||||
update(props.radar.id, (r) => {
|
||||
if (r.profiles.length >= MAX_PROFILES) return
|
||||
r.profiles.push(blankProfile(`Profile ${r.profiles.length + 1}`, r.profiles.length))
|
||||
})
|
||||
}
|
||||
|
||||
function removeProfile(profileId: string): void {
|
||||
update(props.radar.id, (r) => {
|
||||
if (r.profiles.length <= MIN_PROFILES) return
|
||||
r.profiles = r.profiles.filter((p) => p.id !== profileId)
|
||||
})
|
||||
}
|
||||
|
||||
function renameProfile(profileId: string, name: string): void {
|
||||
update(props.radar.id, (r) => {
|
||||
const p = r.profiles.find((x) => x.id === profileId)
|
||||
if (p) p.name = name
|
||||
})
|
||||
}
|
||||
|
||||
function setColor(profileId: string, color: string): void {
|
||||
update(props.radar.id, (r) => {
|
||||
const p = r.profiles.find((x) => x.id === profileId)
|
||||
if (p) p.color = color
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">Profiles</h2>
|
||||
<span class="text-xs text-base-content/50">
|
||||
{{ radar.profiles.length }} / {{ MAX_PROFILES }} · min {{ MIN_PROFILES }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mb-3">
|
||||
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
|
||||
<em>Acme Corp</em>).
|
||||
</p>
|
||||
<ul class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="p in radar.profiles"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded"
|
||||
:style="{ background: p.color }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-sm flex-1"
|
||||
:value="p.name"
|
||||
@input="renameProfile(p.id, ($event.target as HTMLInputElement).value)"
|
||||
placeholder="Profile name"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
:disabled="radar.profiles.length <= MIN_PROFILES"
|
||||
@click="removeProfile(p.id)"
|
||||
:title="
|
||||
radar.profiles.length <= MIN_PROFILES
|
||||
? `Minimum ${MIN_PROFILES} profile`
|
||||
: 'Remove'
|
||||
"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="color in PROFILE_PALETTE"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="w-6 h-6 rounded transition border-2"
|
||||
:style="{ background: color }"
|
||||
:class="
|
||||
p.color === color
|
||||
? 'border-base-content scale-110'
|
||||
: 'border-transparent hover:scale-105'
|
||||
"
|
||||
:aria-label="`Set color ${color}`"
|
||||
@click="setColor(p.id, color)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
class="btn btn-outline btn-sm mt-3"
|
||||
:disabled="radar.profiles.length >= MAX_PROFILES"
|
||||
@click="addProfile"
|
||||
>
|
||||
+ Add profile
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
249
src/components/RadarChart.vue
Normal file
249
src/components/RadarChart.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Radar } from '../types'
|
||||
import { MAX_SCORE } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
radar: Radar
|
||||
/** If true, the SVG renders Radar name (top) and legend (bottom). */
|
||||
withChrome?: boolean
|
||||
}>()
|
||||
|
||||
const WIDTH = 720
|
||||
const HEIGHT = computed(() => (props.withChrome === false ? 600 : 800))
|
||||
const TITLE_H = computed(() => (props.withChrome === false ? 0 : 60))
|
||||
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)
|
||||
const CHART_CX = WIDTH / 2
|
||||
const CHART_CY = computed(() => (CHART_TOP.value + CHART_BOTTOM.value) / 2)
|
||||
const CHART_R = computed(() =>
|
||||
Math.min((CHART_BOTTOM.value - CHART_TOP.value) / 2 - 70, WIDTH / 2 - 100),
|
||||
)
|
||||
|
||||
type Pt = { x: number; y: number }
|
||||
|
||||
function angleFor(i: number, total: number): number {
|
||||
return -Math.PI / 2 + (i * 2 * Math.PI) / total
|
||||
}
|
||||
|
||||
function pointOnAxis(i: number, total: number, ratio: number): Pt {
|
||||
const angle = angleFor(i, total)
|
||||
return {
|
||||
x: CHART_CX + Math.cos(angle) * CHART_R.value * ratio,
|
||||
y: CHART_CY.value + Math.sin(angle) * CHART_R.value * ratio,
|
||||
}
|
||||
}
|
||||
|
||||
const axisPoints = computed<Pt[]>(() =>
|
||||
props.radar.criteria.map((_, i) => pointOnAxis(i, props.radar.criteria.length, 1)),
|
||||
)
|
||||
|
||||
const ringPaths = computed<string[]>(() => {
|
||||
const total = props.radar.criteria.length
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
type ProfileShape = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
path: string
|
||||
vertices: Pt[]
|
||||
}
|
||||
|
||||
const profileShapes = computed<ProfileShape[]>(() => {
|
||||
const total = props.radar.criteria.length
|
||||
if (total < 3) return []
|
||||
return props.radar.profiles.map((profile) => {
|
||||
const vertices = props.radar.criteria.map((criterion, i) => {
|
||||
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'
|
||||
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 }
|
||||
|
||||
const criterionLabels = computed<Label[]>(() => {
|
||||
const total = props.radar.criteria.length
|
||||
return props.radar.criteria.map((criterion, i) => {
|
||||
const labelRadius = CHART_R.value + 28
|
||||
const angle = angleFor(i, total)
|
||||
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'
|
||||
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 }
|
||||
})
|
||||
})
|
||||
|
||||
const scoreRingLabels = computed<Label[]>(() => {
|
||||
if (props.radar.criteria.length < 3) return []
|
||||
return [1, 2, 3, 4, 5].map((score) => {
|
||||
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' }
|
||||
})
|
||||
})
|
||||
|
||||
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 }))
|
||||
})
|
||||
|
||||
const legendLayout = computed(() => {
|
||||
const count = legendItems.value.length
|
||||
if (count === 0) return { rows: [] as Array<typeof legendItems.value> }
|
||||
const perRow = count <= 3 ? count : Math.ceil(count / 2)
|
||||
const rows: Array<typeof legendItems.value> = []
|
||||
for (let i = 0; i < count; i += perRow) {
|
||||
rows.push(legendItems.value.slice(i, i + perRow))
|
||||
}
|
||||
return { rows }
|
||||
})
|
||||
|
||||
const insufficientCriteria = computed(() => props.radar.criteria.length < 3)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
:aria-label="`Radar chart: ${titleText}`"
|
||||
class="w-full h-auto"
|
||||
style="font-family: 'Fredoka', ui-sans-serif, system-ui, sans-serif"
|
||||
>
|
||||
<rect :width="WIDTH" :height="HEIGHT" fill="#ffffff" />
|
||||
|
||||
<!-- Title -->
|
||||
<text
|
||||
v-if="withChrome !== false"
|
||||
:x="WIDTH / 2"
|
||||
:y="TITLE_H / 2 + 4"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-size="28"
|
||||
font-weight="600"
|
||||
fill="#0f172a"
|
||||
>
|
||||
{{ titleText }}
|
||||
</text>
|
||||
|
||||
<!-- Empty state -->
|
||||
<text
|
||||
v-if="insufficientCriteria"
|
||||
:x="CHART_CX"
|
||||
:y="CHART_CY"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-size="18"
|
||||
fill="#94a3b8"
|
||||
>
|
||||
Add at least 3 criteria to see the chart
|
||||
</text>
|
||||
|
||||
<g v-else>
|
||||
<!-- Rings -->
|
||||
<path
|
||||
v-for="(d, i) in ringPaths"
|
||||
:key="`ring-${i}`"
|
||||
:d="d"
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<!-- Axes -->
|
||||
<line
|
||||
v-for="(p, i) in axisPoints"
|
||||
:key="`axis-${i}`"
|
||||
:x1="CHART_CX"
|
||||
:y1="CHART_CY"
|
||||
:x2="p.x"
|
||||
:y2="p.y"
|
||||
stroke="#e2e8f0"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Score ring labels -->
|
||||
<text
|
||||
v-for="(l, i) in scoreRingLabels"
|
||||
:key="`score-${i}`"
|
||||
:x="l.x"
|
||||
:y="l.y"
|
||||
:text-anchor="l.anchor"
|
||||
:dy="l.dy"
|
||||
font-size="11"
|
||||
fill="#94a3b8"
|
||||
>
|
||||
{{ l.text }}
|
||||
</text>
|
||||
|
||||
<!-- Profile shapes -->
|
||||
<g v-for="shape in profileShapes" :key="shape.id">
|
||||
<path
|
||||
:d="shape.path"
|
||||
:fill="shape.color"
|
||||
fill-opacity="0.18"
|
||||
:stroke="shape.color"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
v-for="(v, i) in shape.vertices"
|
||||
:key="`${shape.id}-v-${i}`"
|
||||
:cx="v.x"
|
||||
:cy="v.y"
|
||||
r="3.5"
|
||||
:fill="shape.color"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Criterion labels -->
|
||||
<text
|
||||
v-for="(l, i) in criterionLabels"
|
||||
:key="`label-${i}`"
|
||||
:x="l.x"
|
||||
:y="l.y"
|
||||
:text-anchor="l.anchor"
|
||||
:dy="l.dy"
|
||||
font-size="14"
|
||||
font-weight="500"
|
||||
fill="#0f172a"
|
||||
>
|
||||
{{ l.text }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Legend -->
|
||||
<g v-if="withChrome !== false" :transform="`translate(0, ${CHART_BOTTOM + 24})`">
|
||||
<g
|
||||
v-for="(row, rowIdx) in legendLayout.rows"
|
||||
:key="`row-${rowIdx}`"
|
||||
:transform="`translate(${WIDTH / 2 - (row.length * 140) / 2}, ${rowIdx * 28})`"
|
||||
>
|
||||
<g v-for="(item, i) in row" :key="item.id" :transform="`translate(${i * 140}, 0)`">
|
||||
<rect width="14" height="14" rx="3" :fill="item.color" />
|
||||
<text x="22" y="11" font-size="14" fill="#0f172a">{{ item.name }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
72
src/components/ScoreGrid.vue
Normal file
72
src/components/ScoreGrid.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { Radar } from '../types'
|
||||
import { MIN_SCORE, MAX_SCORE } from '../types'
|
||||
import { useRadars } from '../storage'
|
||||
|
||||
const props = defineProps<{ radar: Radar }>()
|
||||
const { update } = useRadars()
|
||||
|
||||
const SCALE: number[] = []
|
||||
for (let i = MIN_SCORE; i <= MAX_SCORE; i++) SCALE.push(i)
|
||||
|
||||
function getScore(profileId: string, criterionId: string): number {
|
||||
return props.radar.scores[profileId]?.[criterionId] ?? 0
|
||||
}
|
||||
|
||||
function setScore(profileId: string, criterionId: string, value: number): void {
|
||||
update(props.radar.id, (r) => {
|
||||
if (!r.scores[profileId]) r.scores[profileId] = {}
|
||||
r.scores[profileId][criterionId] = value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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' }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in radar.profiles" :key="p.id">
|
||||
<td class="font-medium whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded mr-2 align-middle"
|
||||
:style="{ background: p.color }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ p.name || 'Untitled' }}
|
||||
</td>
|
||||
<td v-for="c in radar.criteria" :key="c.id">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="value in SCALE"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="btn btn-xs"
|
||||
: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}`"
|
||||
>
|
||||
{{ value }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
11
src/router.ts
Normal file
11
src/router.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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 },
|
||||
],
|
||||
})
|
||||
117
src/storage.ts
Normal file
117
src/storage.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Radar, Criterion, Profile } from './types'
|
||||
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from './types'
|
||||
|
||||
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 : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const radars = ref<Radar[]>(loadAll())
|
||||
|
||||
watch(
|
||||
radars,
|
||||
(next) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function uid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
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 ensureScores(radar: Radar): void {
|
||||
for (const profile of radar.profiles) {
|
||||
if (!radar.scores[profile.id]) radar.scores[profile.id] = {}
|
||||
for (const criterion of radar.criteria) {
|
||||
if (radar.scores[profile.id][criterion.id] == null) {
|
||||
radar.scores[profile.id][criterion.id] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
// garbage-collect scores for removed entities
|
||||
for (const profileId of Object.keys(radar.scores)) {
|
||||
if (!radar.profiles.find((p) => p.id === profileId)) {
|
||||
delete radar.scores[profileId]
|
||||
continue
|
||||
}
|
||||
for (const criterionId of Object.keys(radar.scores[profileId])) {
|
||||
if (!radar.criteria.find((c) => c.id === criterionId)) {
|
||||
delete radar.scores[profileId][criterionId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useRadars() {
|
||||
function list(): Radar[] {
|
||||
return radars.value
|
||||
}
|
||||
|
||||
function get(id: string): Radar | undefined {
|
||||
return radars.value.find((r) => r.id === id)
|
||||
}
|
||||
|
||||
function createBlank(name = 'Untitled radar'): Radar {
|
||||
const criteria: Criterion[] = []
|
||||
for (let i = 0; i < MIN_CRITERIA; i++) criteria.push(blankCriterion(`Criterion ${i + 1}`))
|
||||
|
||||
const profiles: Profile[] = []
|
||||
for (let i = 0; i < MIN_PROFILES; i++) profiles.push(blankProfile(`Profile ${i + 1}`, i))
|
||||
|
||||
const now = Date.now()
|
||||
const radar: Radar = {
|
||||
id: uid(),
|
||||
name,
|
||||
criteria,
|
||||
profiles,
|
||||
scores: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
ensureScores(radar)
|
||||
radars.value.push(radar)
|
||||
return radar
|
||||
}
|
||||
|
||||
function update(id: string, mutate: (r: Radar) => void): void {
|
||||
const radar = get(id)
|
||||
if (!radar) return
|
||||
mutate(radar)
|
||||
ensureScores(radar)
|
||||
radar.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
function remove(id: string): void {
|
||||
const idx = radars.value.findIndex((r) => r.id === id)
|
||||
if (idx >= 0) radars.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
radars,
|
||||
list,
|
||||
get,
|
||||
createBlank,
|
||||
update,
|
||||
remove,
|
||||
blankCriterion,
|
||||
blankProfile,
|
||||
}
|
||||
}
|
||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type Criterion = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type Profile = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type Radar = {
|
||||
id: string
|
||||
name: string
|
||||
criteria: Criterion[]
|
||||
profiles: Profile[]
|
||||
/** scores[profileId][criterionId] = 0..5 */
|
||||
scores: Record<string, Record<string, number>>
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const MIN_CRITERIA = 3
|
||||
export const MAX_CRITERIA = 7
|
||||
export const MIN_PROFILES = 1
|
||||
export const MAX_PROFILES = 5
|
||||
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
|
||||
] as const
|
||||
72
src/utils/png.ts
Normal file
72
src/utils/png.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export async function svgToPngBlob(svgElement: SVGSVGElement, scale = 2): Promise<Blob> {
|
||||
const serializer = new XMLSerializer()
|
||||
let source = serializer.serializeToString(svgElement)
|
||||
if (!source.match(/^<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)) {
|
||||
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 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.src = svgUrl
|
||||
})
|
||||
|
||||
const vb = svgElement.viewBox.baseVal
|
||||
const w = vb.width || svgElement.clientWidth || 720
|
||||
const h = vb.height || svgElement.clientHeight || 800
|
||||
|
||||
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'
|
||||
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')
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(svgUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
await navigator.clipboard.write([new ClipItem({ [blob.type]: blob })])
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'radar'
|
||||
)
|
||||
}
|
||||
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