diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..232347e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +ORIGIN=http://localhost:3000 diff --git a/.gitignore b/.gitignore index 3b462cb..def4784 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ Thumbs.db !.env.example !.env.test +# Runtime config (contains password hash) +data/config.json + # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/data/units.json b/data/units.json new file mode 100644 index 0000000..f0a5bb2 --- /dev/null +++ b/data/units.json @@ -0,0 +1,11 @@ +{ + "units": [ + { "id": "blurb", "label": "Blurb", "labelPlural": "Blurbs", "symbol": "bl", "group": "florp", "toBase": 1.0 }, + { "id": "snorp", "label": "Snorp", "labelPlural": "Snorps", "symbol": "sn", "group": "florp", "toBase": 7.5 }, + { "id": "krumble", "label": "Krumble", "labelPlural": "Krumbles", "symbol": "kr", "group": "florp", "toBase": 0.25 }, + { "id": "wobbit", "label": "Wobbit", "labelPlural": "Wobbits", "symbol": "wb", "group": "florp", "toBase": 144.0 } + ], + "groups": [ + { "id": "florp", "label": "Florp Units", "baseUnitId": "blurb", "toUniversal": 1.0 } + ] +} diff --git a/package-lock.json b/package-lock.json index 870a4a0..5fd8d1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,18 @@ "version": "0.0.1", "dependencies": { "@picocss/pico": "^2.1.1", - "big.js": "^7.0.1" + "bcryptjs": "^3.0.3", + "big.js": "^7.0.1", + "cookie": "^1.1.1" }, "devDependencies": { + "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/bcryptjs": "^2.4.6", "@types/big.js": "^6.2.2", + "@types/cookie": "^0.6.0", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "typescript": "^5.9.3", @@ -527,6 +532,112 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -933,6 +1044,22 @@ "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, "node_modules/@sveltejs/adapter-static": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", @@ -985,6 +1112,16 @@ } } }, + "node_modules/@sveltejs/kit/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "6.2.4", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", @@ -1024,6 +1161,13 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/big.js": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", @@ -1045,6 +1189,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1099,6 +1250,15 @@ "node": ">= 0.4" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/big.js": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/big.js/-/big.js-7.0.1.tgz", @@ -1138,14 +1298,24 @@ "node": ">=6" } }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/deepmerge": { @@ -1225,6 +1395,13 @@ "@typescript-eslint/types": "^8.2.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1258,6 +1435,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1345,6 +1568,13 @@ ], "license": "MIT" }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1408,6 +1638,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1498,6 +1749,19 @@ "node": ">=0.10.0" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svelte": { "version": "5.53.12", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz", diff --git a/package.json b/package.json index c63f712..2ccfce3 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,13 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { + "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/bcryptjs": "^2.4.6", "@types/big.js": "^6.2.2", + "@types/cookie": "^0.6.0", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "typescript": "^5.9.3", @@ -23,6 +26,8 @@ }, "dependencies": { "@picocss/pico": "^2.1.1", - "big.js": "^7.0.1" + "bcryptjs": "^3.0.3", + "big.js": "^7.0.1", + "cookie": "^1.1.1" } } diff --git a/scripts/set-password.js b/scripts/set-password.js new file mode 100644 index 0000000..1ae9d9e --- /dev/null +++ b/scripts/set-password.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import bcrypt from 'bcryptjs'; +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const configPath = resolve(__dirname, '../data/config.json'); + +const password = process.argv[2]; +if (!password) { + console.error('Usage: node scripts/set-password.js '); + process.exit(1); +} + +const hash = await bcrypt.hash(password, 12); +const config = JSON.parse(readFileSync(configPath, 'utf8')); +config.passwordHash = hash; +writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); +console.log('Password set successfully.'); diff --git a/src/components/ConversionResult.svelte b/src/components/ConversionResult.svelte index 01a4f80..8ee24b0 100644 --- a/src/components/ConversionResult.svelte +++ b/src/components/ConversionResult.svelte @@ -5,7 +5,9 @@ /** * ConversionResult — displays one conversion result tile. - * Emits onclick (highlight) and onshiftclick (make input unit) events. + * Emits onclick (highlight), onshiftclick (make input unit), + * and onctrlclick (adopt value) events. + * Handles nullable value for cross-group N/A state. */ let { @@ -13,29 +15,27 @@ unit, highlighted, isInputUnit = false, + isYolo = false, onclick, onshiftclick, onctrlclick }: { - value: Big; + value: Big | null; unit: Unit; highlighted: boolean; isInputUnit?: boolean; + isYolo?: boolean; onclick: () => void; onshiftclick: () => void; onctrlclick: () => void; } = $props(); - // Compute once per reactive update; shared between aria-label and display span. - let formattedValue = $derived(formatBig(value)); + let formattedValue = $derived(value !== null ? formatBig(value) : null); function handleClick(e: MouseEvent) { if (e.ctrlKey) { - // Ctrl+click adopts this tile's value as the new input value and promotes it to from-unit. - onctrlclick(); + if (value !== null) onctrlclick(); } else if (e.shiftKey) { - // Shift+click promotes this tile to the from-unit (inputValue unchanged). - // Guard: if it already IS the from-unit, the action would be a no-op. if (!isInputUnit) onshiftclick(); } else { onclick(); @@ -49,13 +49,39 @@ class="result-tile" class:highlighted class:is-input-unit={isInputUnit} + class:is-na={value === null} + class:is-yolo={isYolo} onclick={handleClick} role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter') onclick(); }} - aria-label="{formattedValue} {unit.labelPlural}" + aria-label={value !== null ? `${formattedValue} ${unit.labelPlural}` : `N/A ${unit.labelPlural}`} aria-pressed={highlighted} > - {formattedValue} + {#if value === null} + N/A + {:else} + + {#if isYolo}~{/if}{formattedValue} + + {/if} {unit.labelPlural} ({unit.symbol}) + + diff --git a/src/components/ConverterCard.svelte b/src/components/ConverterCard.svelte index 5e0a255..d34f2ac 100644 --- a/src/components/ConverterCard.svelte +++ b/src/components/ConverterCard.svelte @@ -1,22 +1,19 @@ @@ -110,27 +158,64 @@
- {#if highlightedResult !== null} + {#if highlightedResult !== null && highlightedResult.convertedValue !== null}
- {formatBig(new Big(inputValue))} {fromUnit.labelPlural} + {formatBig(new Big(inputValue))} {fromUnit?.labelPlural} = - {formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural} + + {#if highlightedResult.isYolo}~{/if}{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural} +
+ {:else if highlightedResult !== null && highlightedResult.convertedValue === null} + + N/A — enable YOLO mode for cross-group conversions {:else} Click a result to highlight it {/if}
+
+ +
+ (inputValue = v)} onunitchange={handleUnitChange} onhighlight={handleHighlight} onsetinput={handleSetInput} onctrlclick={handleCtrlClick} /> + + diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..1733832 --- /dev/null +++ b/src/routes/admin/+layout.server.ts @@ -0,0 +1,25 @@ +import { redirect } from '@sveltejs/kit'; +import { loadConfig } from '$lib/server/data'; +import { requireAuth } from '$lib/server/auth'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ request, url }) => { + // Skip auth check for the login page itself + if (url.pathname.endsWith('/login') || url.pathname.endsWith('/login/')) { + return { authenticated: false }; + } + + // Skip auth check for API routes (they handle their own auth) + if (url.pathname.includes('/api/')) { + return { authenticated: true }; + } + + const config = loadConfig(); + const authed = requireAuth(request, config); + + if (!authed) { + throw redirect(302, '/admin/login'); + } + + return { authenticated: true }; +}; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..dd96795 --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children()} +
+ + diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..44b9075 --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,747 @@ + + + + Humor Units — Admin + + +
+

Admin

+ +
+ +{#if checkedUnitIds.size > 0} +
+ {checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'} selected + + + +
+{/if} + +
+ + + + +
+ {#if formError} + + {/if} + {#if formSuccess} +

{formSuccess}

+ {/if} + + {#if (selectedItem?.type === 'unit' || unitFormNew)} + +

{unitFormNew ? 'New Unit' : 'Edit Unit'}

+ + + + + + +
+ + {#if !unitFormNew} + + {/if} +
+ + {:else if (selectedItem?.type === 'group' || groupFormNew)} + +

{groupFormNew ? 'New Group' : 'Edit Group'}

+ + + + +
+ + {#if !groupFormNew} + + {/if} +
+ + {:else} +

Select a unit or group to edit

+ {/if} +
+
+ + +{#if deleteGroupTarget} + {@const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length} + + + +{/if} + + diff --git a/src/routes/admin/api/auth/login/+server.ts b/src/routes/admin/api/auth/login/+server.ts new file mode 100644 index 0000000..38f5912 --- /dev/null +++ b/src/routes/admin/api/auth/login/+server.ts @@ -0,0 +1,51 @@ +import { json } from '@sveltejs/kit'; +import { loadConfig } from '$lib/server/data'; +import { verifyPassword, createSession } from '$lib/server/auth'; +import { serialize } from 'cookie'; +import type { RequestHandler } from './$types'; + +const COOKIE_MAX_AGE = 86400 * 7; // 7 days + +export const POST: RequestHandler = async ({ request, url }) => { + const body = await request.json().catch(() => ({})); + const { password } = body as { password?: string }; + + if (!password) { + return json({ error: 'Password required' }, { status: 400 }); + } + + const config = loadConfig(); + + if (!config.passwordHash) { + console.warn('[humor-units] WARNING: No password hash set. Run: node scripts/set-password.js '); + return json({ error: 'Admin not configured' }, { status: 503 }); + } + + // Timing attack mitigation: ensure response takes at least 500ms on failure + const start = Date.now(); + const valid = await verifyPassword(password, config.passwordHash); + const elapsed = Date.now() - start; + + if (!valid) { + const delay = Math.max(0, 500 - elapsed); + await new Promise((r) => setTimeout(r, delay)); + return json({ error: 'Incorrect password' }, { status: 401 }); + } + + const token = createSession(config.sessionSecret); + const sessionCookie = serialize('hu_session', token, { + httpOnly: true, + sameSite: 'strict', + secure: url.protocol === 'https:', + path: '/', + maxAge: COOKIE_MAX_AGE + }); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': sessionCookie + } + }); +}; diff --git a/src/routes/admin/api/auth/logout/+server.ts b/src/routes/admin/api/auth/logout/+server.ts new file mode 100644 index 0000000..5046086 --- /dev/null +++ b/src/routes/admin/api/auth/logout/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import { serialize } from 'cookie'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = ({ url }) => { + const clearCookie = serialize('hu_session', '', { + httpOnly: true, + sameSite: 'strict', + secure: url.protocol === 'https:', + path: '/', + maxAge: 0 + }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': clearCookie + } + }); +}; diff --git a/src/routes/admin/api/groups/+server.ts b/src/routes/admin/api/groups/+server.ts new file mode 100644 index 0000000..b0e9020 --- /dev/null +++ b/src/routes/admin/api/groups/+server.ts @@ -0,0 +1,64 @@ +import { json } from '@sveltejs/kit'; +import { loadData, saveData, loadConfig } from '$lib/server/data'; +import { requireAuth } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; +import type { Group } from '$lib/types'; + +function auth(request: Request) { + return requireAuth(request, loadConfig()); +} + +const KEBAB_RE = /^[a-z][a-z0-9-]*$/; + +export const GET: RequestHandler = ({ request }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + const data = loadData(); + return json(data.groups); +}; + +export const POST: RequestHandler = async ({ request }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json().catch(() => null); + if (!body) return json({ error: 'Invalid JSON' }, { status: 400 }); + + const { id, label, baseUnitId, toUniversal } = body as Partial; + + if (!id || !KEBAB_RE.test(id)) { + return json({ error: 'id must be non-empty kebab-case' }, { status: 400 }); + } + if (!label) { + return json({ error: 'label is required' }, { status: 400 }); + } + if (typeof toUniversal !== 'number' || toUniversal <= 0) { + return json({ error: 'toUniversal must be a positive number' }, { status: 400 }); + } + + const data = loadData(); + + if (data.groups.some((g) => g.id === id)) { + return json({ error: `Group id '${id}' already exists` }, { status: 400 }); + } + + // baseUnitId must exist and belong to this group (or be empty for new group) + if (baseUnitId) { + const baseUnit = data.units.find((u) => u.id === baseUnitId); + if (!baseUnit) { + return json({ error: `baseUnitId '${baseUnitId}' does not exist` }, { status: 400 }); + } + if (baseUnit.group !== id && baseUnit.group !== null) { + return json({ error: `baseUnitId '${baseUnitId}' does not belong to this group` }, { status: 400 }); + } + } + + const newGroup: Group = { + id, + label, + baseUnitId: baseUnitId ?? '', + toUniversal + }; + data.groups.push(newGroup); + saveData(data); + + return json(newGroup, { status: 201 }); +}; diff --git a/src/routes/admin/api/groups/[id]/+server.ts b/src/routes/admin/api/groups/[id]/+server.ts new file mode 100644 index 0000000..1a2674a --- /dev/null +++ b/src/routes/admin/api/groups/[id]/+server.ts @@ -0,0 +1,111 @@ +import { json } from '@sveltejs/kit'; +import { loadData, saveData, loadConfig } from '$lib/server/data'; +import { requireAuth } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; +import type { Group } from '$lib/types'; + +function auth(request: Request) { + return requireAuth(request, loadConfig()); +} + +export const PUT: RequestHandler = async ({ request, params }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = params; + const body = await request.json().catch(() => null); + if (!body) return json({ error: 'Invalid JSON' }, { status: 400 }); + + const data = loadData(); + const gIdx = data.groups.findIndex((g) => g.id === id); + if (gIdx === -1) return json({ error: 'Group not found' }, { status: 404 }); + + const patch = body as Partial; + const currentGroup = data.groups[gIdx]; + + // Special case: changing baseUnitId requires recalculating all unit toBase values + if (patch.baseUnitId !== undefined && patch.baseUnitId !== currentGroup.baseUnitId) { + const newBaseId = patch.baseUnitId; + const newBaseUnit = data.units.find((u) => u.id === newBaseId && u.group === id); + if (!newBaseUnit) { + return json({ error: `baseUnitId '${newBaseId}' not found in this group` }, { status: 400 }); + } + + const X = newBaseUnit.toBase; // Current toBase of the new base unit + // Recalculate all units in this group: unit.toBase = unit.toBase / X + // Then set new base unit toBase = 1.0 + for (const unit of data.units) { + if (unit.group === id) { + if (unit.id === newBaseId) { + unit.toBase = 1.0; + } else { + unit.toBase = unit.toBase / X; + } + } + } + } + + const updated: Group = { ...currentGroup, ...patch }; + data.groups[gIdx] = updated; + saveData(data); + + return json(updated); +}; + +export const DELETE: RequestHandler = async ({ request, params }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = params; + const body = await request.json().catch(() => ({})); + const { action, targetGroupId, toBaseAction } = body as { + action?: 'reassign' | 'orphan'; + targetGroupId?: string; + toBaseAction?: 'recalculate' | 'reset' | 'keep'; + }; + + if (!action || (action !== 'reassign' && action !== 'orphan')) { + return json({ error: "action must be 'reassign' or 'orphan'" }, { status: 400 }); + } + + const data = loadData(); + const gIdx = data.groups.findIndex((g) => g.id === id); + if (gIdx === -1) return json({ error: 'Group not found' }, { status: 404 }); + + const fromGroup = data.groups[gIdx]; + const unitsInGroup = data.units.filter((u) => u.group === id); + + if (action === 'reassign') { + if (!targetGroupId) { + return json({ error: 'targetGroupId required for reassign' }, { status: 400 }); + } + if (targetGroupId === id) { + return json({ error: 'Cannot reassign to the same group' }, { status: 400 }); + } + const toGroup = data.groups.find((g) => g.id === targetGroupId); + if (!toGroup) { + return json({ error: `Target group '${targetGroupId}' not found` }, { status: 404 }); + } + + for (const unit of data.units) { + if (unit.group === id) { + if (toBaseAction === 'recalculate') { + unit.toBase = (unit.toBase * fromGroup.toUniversal) / toGroup.toUniversal; + } else if (toBaseAction === 'reset') { + unit.toBase = 1.0; + } + // 'keep' or default: no change to toBase + unit.group = targetGroupId; + } + } + } else if (action === 'orphan') { + for (const unit of data.units) { + if (unit.group === id) { + unit.group = null; + } + } + } + + data.groups.splice(gIdx, 1); + saveData(data); + + return json({ ok: true, unitsAffected: unitsInGroup.length }); +}; diff --git a/src/routes/admin/api/units/+server.ts b/src/routes/admin/api/units/+server.ts new file mode 100644 index 0000000..f81d920 --- /dev/null +++ b/src/routes/admin/api/units/+server.ts @@ -0,0 +1,59 @@ +import { json } from '@sveltejs/kit'; +import { loadData, saveData, loadConfig } from '$lib/server/data'; +import { requireAuth } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; +import type { Unit } from '$lib/types'; + +function auth(request: Request) { + return requireAuth(request, loadConfig()); +} + +const KEBAB_RE = /^[a-z][a-z0-9-]*$/; + +export const GET: RequestHandler = ({ request }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + const data = loadData(); + return json(data.units); +}; + +export const POST: RequestHandler = async ({ request }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json().catch(() => null); + if (!body) return json({ error: 'Invalid JSON' }, { status: 400 }); + + const { id, label, labelPlural, symbol, group, toBase } = body as Partial; + + // Validation + if (!id || !KEBAB_RE.test(id)) { + return json({ error: 'id must be non-empty kebab-case' }, { status: 400 }); + } + if (!label || !labelPlural || !symbol) { + return json({ error: 'label, labelPlural, and symbol are required' }, { status: 400 }); + } + if (typeof toBase !== 'number' || toBase <= 0) { + return json({ error: 'toBase must be a positive number' }, { status: 400 }); + } + + const data = loadData(); + + if (data.units.some((u) => u.id === id)) { + return json({ error: `Unit id '${id}' already exists` }, { status: 400 }); + } + if (group !== null && group !== undefined && !data.groups.some((g) => g.id === group)) { + return json({ error: `Group '${group}' does not exist` }, { status: 400 }); + } + + const newUnit: Unit = { + id, + label, + labelPlural, + symbol, + group: group ?? null, + toBase + }; + data.units.push(newUnit); + saveData(data); + + return json(newUnit, { status: 201 }); +}; diff --git a/src/routes/admin/api/units/[id]/+server.ts b/src/routes/admin/api/units/[id]/+server.ts new file mode 100644 index 0000000..817f01a --- /dev/null +++ b/src/routes/admin/api/units/[id]/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import { loadData, saveData, loadConfig } from '$lib/server/data'; +import { requireAuth } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; +import type { Unit } from '$lib/types'; + +function auth(request: Request) { + return requireAuth(request, loadConfig()); +} + +const KEBAB_RE = /^[a-z][a-z0-9-]*$/; + +export const PUT: RequestHandler = async ({ request, params }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = params; + const body = await request.json().catch(() => null); + if (!body) return json({ error: 'Invalid JSON' }, { status: 400 }); + + const data = loadData(); + const idx = data.units.findIndex((u) => u.id === id); + if (idx === -1) return json({ error: 'Unit not found' }, { status: 404 }); + + const patch = body as Partial; + + // If id is changing, validate new id + if (patch.id !== undefined && patch.id !== id) { + if (!KEBAB_RE.test(patch.id)) { + return json({ error: 'id must be kebab-case' }, { status: 400 }); + } + if (data.units.some((u) => u.id === patch.id)) { + return json({ error: `Unit id '${patch.id}' already exists` }, { status: 400 }); + } + } + if (patch.toBase !== undefined && patch.toBase <= 0) { + return json({ error: 'toBase must be positive' }, { status: 400 }); + } + if (patch.group !== null && patch.group !== undefined && !data.groups.some((g) => g.id === patch.group)) { + return json({ error: `Group '${patch.group}' does not exist` }, { status: 400 }); + } + + const updated: Unit = { ...data.units[idx], ...patch }; + data.units[idx] = updated; + saveData(data); + + return json(updated); +}; + +export const DELETE: RequestHandler = ({ request, params }) => { + if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = params; + const data = loadData(); + const idx = data.units.findIndex((u) => u.id === id); + if (idx === -1) return json({ error: 'Unit not found' }, { status: 404 }); + + data.units.splice(idx, 1); + saveData(data); + + return json({ ok: true }); +}; diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte new file mode 100644 index 0000000..919e1a7 --- /dev/null +++ b/src/routes/admin/login/+page.svelte @@ -0,0 +1,85 @@ + + + + Humor Units — Admin Login + + + + + diff --git a/src/routes/api/units/+server.ts b/src/routes/api/units/+server.ts new file mode 100644 index 0000000..07e1a08 --- /dev/null +++ b/src/routes/api/units/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import { loadData } from '$lib/server/data'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = () => { + const data = loadData(); + return json(data, { + headers: { + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/svelte.config.js b/svelte.config.js index 728a83e..ad116dc 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,11 +1,9 @@ -import adapter from '@sveltejs/adapter-static'; +import adapter from '@sveltejs/adapter-node'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { - adapter: adapter({ - fallback: '404.html' - }) + adapter: adapter() }, vitePlugin: { dynamicCompileOptions: ({ filename }) =>