feat: admin page with dynamic converter loading
Implements full admin interface for managing units and groups.
Migrates converter from static imports to server-side data loading.
- Switch adapter-static → adapter-node
- Add bcryptjs session auth with HMAC-signed cookies
- Add data/units.json and data/config.json data layer
- Add atomic file writes via temp-file rename
- Add public GET /api/units endpoint
- Add auth-gated admin CRUD API for units and groups
- Add two-panel admin UI with group tree and edit forms
- Add YOLO mode toggle for cross-group conversions
- Add visual group dividers in converter results grid
- Update ResultItem type for nullable convertedValue and isYolo flag
- Group deletion supports reassign/orphan with toBase recalculation
Rollback point: 067fd44
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ORIGIN=http://localhost:3000
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,6 +18,9 @@ Thumbs.db
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Runtime config (contains password hash)
|
||||||
|
data/config.json
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
11
data/units.json
Normal file
11
data/units.json
Normal file
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
276
package-lock.json
generated
276
package-lock.json
generated
@@ -9,13 +9,18 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"big.js": "^7.0.1"
|
"bcryptjs": "^3.0.3",
|
||||||
|
"big.js": "^7.0.1",
|
||||||
|
"cookie": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/big.js": "^6.2.2",
|
"@types/big.js": "^6.2.2",
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -527,6 +532,112 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
"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"
|
"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": {
|
"node_modules/@sveltejs/adapter-static": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
|
"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": {
|
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||||
"version": "6.2.4",
|
"version": "6.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
|
"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"
|
"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": {
|
"node_modules/@types/big.js": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz",
|
||||||
@@ -1045,6 +1189,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -1099,6 +1250,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/big.js": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/big.js/-/big.js-7.0.1.tgz",
|
||||||
@@ -1138,14 +1298,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/commondir": {
|
||||||
"version": "0.6.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
|
||||||
"dev": true,
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
@@ -1225,6 +1395,13 @@
|
|||||||
"@typescript-eslint/types": "^8.2.0"
|
"@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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": "^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": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
@@ -1345,6 +1568,13 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1408,6 +1638,27 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -1498,6 +1749,19 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/svelte": {
|
||||||
"version": "5.53.12",
|
"version": "5.53.12",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz",
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/big.js": "^6.2.2",
|
"@types/big.js": "^6.2.2",
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -23,6 +26,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"big.js": "^7.0.1"
|
"bcryptjs": "^3.0.3",
|
||||||
|
"big.js": "^7.0.1",
|
||||||
|
"cookie": "^1.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
scripts/set-password.js
Normal file
20
scripts/set-password.js
Normal file
@@ -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 <password>');
|
||||||
|
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.');
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ConversionResult — displays one conversion result tile.
|
* 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 {
|
let {
|
||||||
@@ -13,29 +15,27 @@
|
|||||||
unit,
|
unit,
|
||||||
highlighted,
|
highlighted,
|
||||||
isInputUnit = false,
|
isInputUnit = false,
|
||||||
|
isYolo = false,
|
||||||
onclick,
|
onclick,
|
||||||
onshiftclick,
|
onshiftclick,
|
||||||
onctrlclick
|
onctrlclick
|
||||||
}: {
|
}: {
|
||||||
value: Big;
|
value: Big | null;
|
||||||
unit: Unit;
|
unit: Unit;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
isInputUnit?: boolean;
|
isInputUnit?: boolean;
|
||||||
|
isYolo?: boolean;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
onshiftclick: () => void;
|
onshiftclick: () => void;
|
||||||
onctrlclick: () => void;
|
onctrlclick: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Compute once per reactive update; shared between aria-label and display span.
|
let formattedValue = $derived(value !== null ? formatBig(value) : null);
|
||||||
let formattedValue = $derived(formatBig(value));
|
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey) {
|
||||||
// Ctrl+click adopts this tile's value as the new input value and promotes it to from-unit.
|
if (value !== null) onctrlclick();
|
||||||
onctrlclick();
|
|
||||||
} else if (e.shiftKey) {
|
} 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();
|
if (!isInputUnit) onshiftclick();
|
||||||
} else {
|
} else {
|
||||||
onclick();
|
onclick();
|
||||||
@@ -49,13 +49,39 @@
|
|||||||
class="result-tile"
|
class="result-tile"
|
||||||
class:highlighted
|
class:highlighted
|
||||||
class:is-input-unit={isInputUnit}
|
class:is-input-unit={isInputUnit}
|
||||||
|
class:is-na={value === null}
|
||||||
|
class:is-yolo={isYolo}
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') onclick(); }}
|
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}
|
aria-pressed={highlighted}
|
||||||
>
|
>
|
||||||
<span class="result-value">{formattedValue}</span>
|
{#if value === null}
|
||||||
|
<span class="result-value result-na">N/A</span>
|
||||||
|
{:else}
|
||||||
|
<span class="result-value">
|
||||||
|
{#if isYolo}<span class="yolo-tilde">~</span>{/if}{formattedValue}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<span class="result-label">{unit.labelPlural} ({unit.symbol})</span>
|
<span class="result-label">{unit.labelPlural} ({unit.symbol})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.is-na {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-na {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yolo-tilde {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Unit, ResultItem } from '$lib/types';
|
import type { Unit, Group, ResultItem } from '$lib/types';
|
||||||
import type { Big } from 'big.js';
|
import type { Big } from 'big.js';
|
||||||
import { MIN_COLUMN_WIDTH, GRID_GAP } from '$lib/config';
|
import { MIN_COLUMN_WIDTH, GRID_GAP } from '$lib/config';
|
||||||
import UnitInput from './UnitInput.svelte';
|
import UnitInput from './UnitInput.svelte';
|
||||||
import UnitDropdown from './UnitDropdown.svelte';
|
import UnitDropdown from './UnitDropdown.svelte';
|
||||||
import ConversionResult from './ConversionResult.svelte';
|
import ConversionResult from './ConversionResult.svelte';
|
||||||
|
|
||||||
/**
|
|
||||||
* ConverterCard — groups UnitInput + UnitDropdown + results grid + hotkey hint.
|
|
||||||
* Emits events upward; does NOT contain NL input (Phase 3).
|
|
||||||
*/
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
units,
|
units,
|
||||||
|
groups,
|
||||||
inputValue,
|
inputValue,
|
||||||
fromUnitId,
|
fromUnitId,
|
||||||
highlightedUnitId,
|
highlightedUnitId,
|
||||||
results,
|
results,
|
||||||
|
orderedResults,
|
||||||
oninputchange,
|
oninputchange,
|
||||||
onunitchange,
|
onunitchange,
|
||||||
onhighlight,
|
onhighlight,
|
||||||
@@ -24,10 +21,12 @@
|
|||||||
onctrlclick
|
onctrlclick
|
||||||
}: {
|
}: {
|
||||||
units: Unit[];
|
units: Unit[];
|
||||||
|
groups: Group[];
|
||||||
inputValue: number;
|
inputValue: number;
|
||||||
fromUnitId: string;
|
fromUnitId: string;
|
||||||
highlightedUnitId: string | null;
|
highlightedUnitId: string | null;
|
||||||
results: ResultItem[];
|
results: ResultItem[];
|
||||||
|
orderedResults: { groupLabel: string | null; items: ResultItem[] }[];
|
||||||
oninputchange: (v: number) => void;
|
oninputchange: (v: number) => void;
|
||||||
onunitchange: (id: string) => void;
|
onunitchange: (id: string) => void;
|
||||||
onhighlight: (id: string) => void;
|
onhighlight: (id: string) => void;
|
||||||
@@ -44,21 +43,43 @@
|
|||||||
<UnitDropdown {units} value={fromUnitId} onchange={onunitchange} />
|
<UnitDropdown {units} value={fromUnitId} onchange={onunitchange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="results-grid" style={gridStyle}>
|
{#each orderedResults as section}
|
||||||
{#each results as item (item.unit.id)}
|
{#if orderedResults.length > 1 && section.groupLabel}
|
||||||
<ConversionResult
|
<div class="group-divider">{section.groupLabel}</div>
|
||||||
value={item.convertedValue}
|
{/if}
|
||||||
unit={item.unit}
|
<div class="results-grid" style={gridStyle}>
|
||||||
highlighted={item.unit.id === highlightedUnitId}
|
{#each section.items as item (item.unit.id)}
|
||||||
isInputUnit={item.unit.id === fromUnitId}
|
<ConversionResult
|
||||||
onclick={() => onhighlight(item.unit.id)}
|
value={item.convertedValue}
|
||||||
onshiftclick={() => onsetinput(item.unit.id)}
|
isYolo={item.isYolo}
|
||||||
onctrlclick={() => onctrlclick(item.unit.id, item.convertedValue)}
|
unit={item.unit}
|
||||||
/>
|
highlighted={item.unit.id === highlightedUnitId}
|
||||||
{/each}
|
isInputUnit={item.unit.id === fromUnitId}
|
||||||
</div>
|
onclick={() => onhighlight(item.unit.id)}
|
||||||
|
onshiftclick={() => onsetinput(item.unit.id)}
|
||||||
|
onctrlclick={() => {
|
||||||
|
if (item.convertedValue !== null) onctrlclick(item.unit.id, item.convertedValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<p class="hotkey-hint">
|
<p class="hotkey-hint">
|
||||||
<small>Click to highlight · Shift+click to change input unit · Ctrl+click to adopt value as input</small>
|
<small>Click to highlight · Shift+click to change input unit · Ctrl+click to adopt value as input</small>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group-divider {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Big } from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import type { Unit } from './types';
|
import type { Unit, Group } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a value from one unit to another using canonical base-unit factors.
|
* Convert a value from one unit to another using canonical base-unit factors.
|
||||||
* Uses big.js for all arithmetic to avoid floating-point precision loss.
|
* Uses big.js for all arithmetic to avoid floating-point precision loss.
|
||||||
* Pure function — no side effects, no imports of the unit registry.
|
* Pure function — no side effects, no imports of the unit registry.
|
||||||
|
* Only valid for units in the same group.
|
||||||
*/
|
*/
|
||||||
export function convert(value: Big, from: Unit, to: Unit): Big {
|
export function convert(value: Big, from: Unit, to: Unit): Big {
|
||||||
if (from.id === to.id) return value;
|
if (from.id === to.id) return value;
|
||||||
@@ -29,3 +30,29 @@ export function convertById(
|
|||||||
if (!to) throw new Error(`Unknown unit id: ${toId}`);
|
if (!to) throw new Error(`Unknown unit id: ${toId}`);
|
||||||
return convert(Big(value), from, to);
|
return convert(Big(value), from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if from and to are in the same group (both non-null and equal).
|
||||||
|
*/
|
||||||
|
export function canConvert(from: Unit, to: Unit): boolean {
|
||||||
|
return from.group !== null && from.group === to.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-group "YOLO" conversion using toUniversal scale factors.
|
||||||
|
* Path: value * from.toBase * fromGroup.toUniversal / toGroup.toUniversal / to.toBase
|
||||||
|
*/
|
||||||
|
export function convertYolo(value: Big, from: Unit, to: Unit, groups: Group[]): Big {
|
||||||
|
if (from.id === to.id) return value;
|
||||||
|
const fromGroup = groups.find((g) => g.id === from.group);
|
||||||
|
const toGroup = groups.find((g) => g.id === to.group);
|
||||||
|
if (!fromGroup || !toGroup) {
|
||||||
|
// No group info — fall back to raw toBase ratio
|
||||||
|
return value.times(from.toBase).div(to.toBase);
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.times(from.toBase)
|
||||||
|
.times(fromGroup.toUniversal)
|
||||||
|
.div(toGroup.toUniversal)
|
||||||
|
.div(to.toBase);
|
||||||
|
}
|
||||||
|
|||||||
63
src/lib/server/auth.ts
Normal file
63
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import { parse } from 'cookie';
|
||||||
|
import type { AppConfig } from './data';
|
||||||
|
|
||||||
|
const BCRYPT_COST = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(plain: string): Promise<string> {
|
||||||
|
return bcrypt.hash(plain, BCRYPT_COST);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
if (!hash) return false;
|
||||||
|
return bcrypt.compare(plain, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HMAC-signed session token.
|
||||||
|
* Format: `timestamp.hmac`
|
||||||
|
*/
|
||||||
|
export function createSession(secret: string): string {
|
||||||
|
const timestamp = Date.now().toString();
|
||||||
|
const hmac = createHmac('sha256', secret).update(timestamp).digest('hex');
|
||||||
|
return `${timestamp}.${hmac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an HMAC-signed session token.
|
||||||
|
* Returns false if format is wrong, HMAC doesn't match, or token is >7 days old.
|
||||||
|
*/
|
||||||
|
export function verifySession(token: string, secret: string): boolean {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const [timestamp, providedHmac] = parts;
|
||||||
|
|
||||||
|
// Check age (7 days)
|
||||||
|
const ts = parseInt(timestamp, 10);
|
||||||
|
if (isNaN(ts)) return false;
|
||||||
|
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) return false;
|
||||||
|
|
||||||
|
// Verify HMAC using timing-safe comparison
|
||||||
|
const expectedHmac = createHmac('sha256', secret).update(timestamp).digest('hex');
|
||||||
|
try {
|
||||||
|
const a = Buffer.from(providedHmac, 'hex');
|
||||||
|
const b = Buffer.from(expectedHmac, 'hex');
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the hu_session cookie in the request.
|
||||||
|
* Returns true if a valid session is present.
|
||||||
|
*/
|
||||||
|
export function requireAuth(request: Request, config: AppConfig): boolean {
|
||||||
|
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||||
|
const cookies = parse(cookieHeader);
|
||||||
|
const token = cookies['hu_session'];
|
||||||
|
if (!token) return false;
|
||||||
|
return verifySession(token, config.sessionSecret);
|
||||||
|
}
|
||||||
40
src/lib/server/data.ts
Normal file
40
src/lib/server/data.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { readFileSync, writeFileSync, renameSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import type { UnitsData } from '$lib/types';
|
||||||
|
|
||||||
|
// Path is relative to project root (where the server runs from)
|
||||||
|
const DATA_DIR = resolve('data');
|
||||||
|
const UNITS_PATH = resolve(DATA_DIR, 'units.json');
|
||||||
|
const CONFIG_PATH = resolve(DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
adminPath: string;
|
||||||
|
passwordHash: string;
|
||||||
|
sessionSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read units.json synchronously. Returns parsed UnitsData. */
|
||||||
|
export function loadData(): UnitsData {
|
||||||
|
const raw = readFileSync(UNITS_PATH, 'utf8');
|
||||||
|
return JSON.parse(raw) as UnitsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write full UnitsData atomically (write to temp, rename). */
|
||||||
|
export function saveData(data: UnitsData): void {
|
||||||
|
const tmp = UNITS_PATH + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||||
|
renameSync(tmp, UNITS_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read config.json synchronously. */
|
||||||
|
export function loadConfig(): AppConfig {
|
||||||
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
return JSON.parse(raw) as AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write config.json atomically. */
|
||||||
|
export function saveConfig(config: AppConfig): void {
|
||||||
|
const tmp = CONFIG_PATH + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
renameSync(tmp, CONFIG_PATH);
|
||||||
|
}
|
||||||
@@ -22,19 +22,31 @@ export interface Unit {
|
|||||||
*/
|
*/
|
||||||
toBase: number;
|
toBase: number;
|
||||||
|
|
||||||
/** Optional grouping tag for multi-group converters. */
|
/** Group this unit belongs to, or null if orphaned. */
|
||||||
group?: string;
|
group: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A named collection of units sharing a base unit. */
|
/** A group of units sharing a base unit and a universal scale factor. */
|
||||||
export interface UnitGroup {
|
export interface Group {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
units: Unit[];
|
/** ID of the unit that serves as the base (toBase = 1.0) for this group. */
|
||||||
|
baseUnitId: string;
|
||||||
|
/** Relative scale for cross-group YOLO conversions. */
|
||||||
|
toUniversal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One computed conversion result — unit plus its converted Big value. */
|
/** Full data payload for units and groups. */
|
||||||
|
export interface UnitsData {
|
||||||
|
units: Unit[];
|
||||||
|
groups: Group[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One computed conversion result — unit plus its converted value. */
|
||||||
export interface ResultItem {
|
export interface ResultItem {
|
||||||
unit: Unit;
|
unit: Unit;
|
||||||
convertedValue: import('big.js').Big;
|
/** Converted value, or null if N/A (cross-group with YOLO off). */
|
||||||
|
convertedValue: import('big.js').Big | null;
|
||||||
|
/** True if this result used cross-group YOLO conversion. */
|
||||||
|
isYolo: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const prerender = true;
|
// No prerender — adapter-node handles SSR dynamically
|
||||||
|
|||||||
10
src/routes/+page.server.ts
Normal file
10
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { loadData } from '$lib/server/data';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = () => {
|
||||||
|
const data = loadData();
|
||||||
|
return {
|
||||||
|
units: data.units,
|
||||||
|
groups: data.groups
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,26 +2,47 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { florpUnits } from '$lib/units/definitions';
|
import { convert, convertYolo, canConvert } from '$lib/convert';
|
||||||
import { convertById } from '$lib/convert';
|
|
||||||
import { FRACTIONAL_DIGITS } from '$lib/config';
|
import { FRACTIONAL_DIGITS } from '$lib/config';
|
||||||
import { formatBig } from '$lib/format';
|
import { formatBig } from '$lib/format';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import type { Unit, ResultItem } from '$lib/types';
|
import type { Unit, Group, ResultItem } from '$lib/types';
|
||||||
import ConverterCard from '../components/ConverterCard.svelte';
|
import ConverterCard from '../components/ConverterCard.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converter page — owns all reactive state.
|
* Converter page — loads units dynamically from server props.
|
||||||
* Phase 2: live conversion, URL state, highlight interaction.
|
* Supports same-group and cross-group (YOLO) conversions.
|
||||||
* Phase 3 will add NL input.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const units: Unit[] = florpUnits;
|
let { data }: { data: { units: Unit[]; groups: Group[] } } = $props();
|
||||||
|
|
||||||
|
let units: Unit[] = $derived(data.units);
|
||||||
|
let groups: Group[] = $derived(data.groups);
|
||||||
|
|
||||||
// ── Reactive state ──────────────────────────────────────────────────────────
|
// ── Reactive state ──────────────────────────────────────────────────────────
|
||||||
let inputValue = $state<number>(1);
|
let inputValue = $state<number>(1);
|
||||||
let fromUnitId = $state<string>(units[0].id);
|
let fromUnitId = $state<string>('');
|
||||||
let highlightedUnitId = $state<string | null>(null);
|
let highlightedUnitId = $state<string | null>(null);
|
||||||
|
let yoloMode = $state<boolean>(false);
|
||||||
|
|
||||||
|
// Initialize fromUnitId to first unit once data arrives
|
||||||
|
$effect(() => {
|
||||||
|
if (fromUnitId === '' && units.length > 0) {
|
||||||
|
fromUnitId = units[0].id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Load YOLO preference from localStorage ──────────────────────────────────
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const stored = localStorage.getItem('yolo-mode');
|
||||||
|
if (stored === 'true') yoloMode = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
localStorage.setItem('yolo-mode', String(yoloMode));
|
||||||
|
});
|
||||||
|
|
||||||
// ── Initialize from URL params on first client-side load ───────────────────
|
// ── Initialize from URL params on first client-side load ───────────────────
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -34,35 +55,52 @@
|
|||||||
const n = parseFloat(pValue);
|
const n = parseFloat(pValue);
|
||||||
if (!isNaN(n)) inputValue = n;
|
if (!isNaN(n)) inputValue = n;
|
||||||
}
|
}
|
||||||
if (pFrom !== null && units.some((u) => u.id === pFrom)) {
|
if (pFrom !== null && data.units.some((u) => u.id === pFrom)) {
|
||||||
fromUnitId = pFrom;
|
fromUnitId = pFrom;
|
||||||
}
|
}
|
||||||
if (pHighlight !== null && units.some((u) => u.id === pHighlight)) {
|
if (pHighlight !== null && data.units.some((u) => u.id === pHighlight)) {
|
||||||
highlightedUnitId = pHighlight;
|
highlightedUnitId = pHighlight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Derived: results for ALL units in definition order ─────────────────────
|
// ── Derived: results for ALL units ─────────────────────────────────────────
|
||||||
// convert.ts already handles the same-unit identity (from.id === to.id → return value),
|
let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]);
|
||||||
// so no special-casing needed here for the from-unit.
|
|
||||||
let results = $derived<ResultItem[]>(
|
let results: ResultItem[] = $derived(
|
||||||
units.map((u) => ({
|
fromUnit
|
||||||
unit: u,
|
? units.map((u) => {
|
||||||
convertedValue: convertById(inputValue, fromUnitId, u.id, units)
|
const sameGroup = canConvert(fromUnit, u);
|
||||||
}))
|
if (sameGroup) {
|
||||||
|
return {
|
||||||
|
unit: u,
|
||||||
|
convertedValue: convert(Big(inputValue), fromUnit, u),
|
||||||
|
isYolo: false
|
||||||
|
};
|
||||||
|
} else if (yoloMode) {
|
||||||
|
return {
|
||||||
|
unit: u,
|
||||||
|
convertedValue: convertYolo(Big(inputValue), fromUnit, u, groups),
|
||||||
|
isYolo: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
unit: u,
|
||||||
|
convertedValue: null,
|
||||||
|
isYolo: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Derived: highlighted result (unit + value) sourced from full results ────
|
// ── Derived: highlighted result ─────────────────────────────────────────────
|
||||||
// Results now includes all units, so from-unit can be highlighted too.
|
|
||||||
let highlightedResult = $derived(
|
let highlightedResult = $derived(
|
||||||
highlightedUnitId !== null
|
highlightedUnitId !== null
|
||||||
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
|
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
let fromUnit = $derived(units.find(u => u.id === fromUnitId) ?? units[0]);
|
// ── URL sync ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── URL sync: update URL only when serialized params actually change ────────
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -80,7 +118,6 @@
|
|||||||
// ── Event handlers ──────────────────────────────────────────────────────────
|
// ── Event handlers ──────────────────────────────────────────────────────────
|
||||||
function handleUnitChange(id: string) {
|
function handleUnitChange(id: string) {
|
||||||
fromUnitId = id;
|
fromUnitId = id;
|
||||||
// Clear highlight if the selected unit becomes the new from-unit.
|
|
||||||
if (highlightedUnitId === id) {
|
if (highlightedUnitId === id) {
|
||||||
highlightedUnitId = null;
|
highlightedUnitId = null;
|
||||||
}
|
}
|
||||||
@@ -91,18 +128,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSetInput(id: string) {
|
function handleSetInput(id: string) {
|
||||||
// Shift+click: promote result unit to from-unit.
|
|
||||||
// inputValue is NOT changed — outputs recalculate from the same inputValue with the new fromUnitId.
|
|
||||||
// highlightedUnitId is NOT cleared — the two states are independent.
|
|
||||||
fromUnitId = id;
|
fromUnitId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCtrlClick(id: string, convertedValue: Big) {
|
function handleCtrlClick(id: string, convertedValue: Big) {
|
||||||
// Ctrl+click: adopt this tile's displayed value as the new inputValue and promote it to from-unit.
|
|
||||||
// This is the "pivot" gesture.
|
|
||||||
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
|
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
|
||||||
fromUnitId = id;
|
fromUnitId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group units for display ─────────────────────────────────────────────────
|
||||||
|
let orderedResults: { groupLabel: string | null; items: ResultItem[] }[] = $derived.by(() => {
|
||||||
|
const sections: { groupLabel: string | null; items: ResultItem[] }[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
const items = results.filter((r) => r.unit.group === group.id);
|
||||||
|
if (items.length > 0) {
|
||||||
|
sections.push({ groupLabel: group.label, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orphaned = results.filter((r) => r.unit.group === null);
|
||||||
|
if (orphaned.length > 0) {
|
||||||
|
sections.push({ groupLabel: 'Ungrouped', items: orphaned });
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -110,27 +158,64 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="highlighted-result" class:has-highlight={highlightedResult !== null} aria-live="polite">
|
<div class="highlighted-result" class:has-highlight={highlightedResult !== null} aria-live="polite">
|
||||||
{#if highlightedResult !== null}
|
{#if highlightedResult !== null && highlightedResult.convertedValue !== null}
|
||||||
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
||||||
<div class="highlighted-equation">
|
<div class="highlighted-equation">
|
||||||
<span class="highlighted-eq-from">{formatBig(new Big(inputValue))} {fromUnit.labelPlural}</span>
|
<span class="highlighted-eq-from">{formatBig(new Big(inputValue))} {fromUnit?.labelPlural}</span>
|
||||||
<span class="highlighted-eq-sep">=</span>
|
<span class="highlighted-eq-sep">=</span>
|
||||||
<span class="highlighted-eq-to">{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural}</span>
|
<span class="highlighted-eq-to">
|
||||||
|
{#if highlightedResult.isYolo}<span class="yolo-marker">~</span>{/if}{formatBig(highlightedResult.convertedValue)} {highlightedResult.unit.labelPlural}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if highlightedResult !== null && highlightedResult.convertedValue === null}
|
||||||
|
<button class="highlight-dismiss" onclick={() => (highlightedUnitId = null)} aria-label="Remove highlight">×</button>
|
||||||
|
<span class="highlighted-placeholder">N/A — enable YOLO mode for cross-group conversions</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="highlighted-placeholder">Click a result to highlight it</span>
|
<span class="highlighted-placeholder">Click a result to highlight it</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="yolo-toggle-row">
|
||||||
|
<label class="yolo-label">
|
||||||
|
<input type="checkbox" bind:checked={yoloMode} role="switch" />
|
||||||
|
YOLO mode <small>(cross-group conversions)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConverterCard
|
<ConverterCard
|
||||||
{units}
|
{units}
|
||||||
|
{groups}
|
||||||
{inputValue}
|
{inputValue}
|
||||||
{fromUnitId}
|
{fromUnitId}
|
||||||
{highlightedUnitId}
|
{highlightedUnitId}
|
||||||
{results}
|
{results}
|
||||||
|
{orderedResults}
|
||||||
oninputchange={(v) => (inputValue = v)}
|
oninputchange={(v) => (inputValue = v)}
|
||||||
onunitchange={handleUnitChange}
|
onunitchange={handleUnitChange}
|
||||||
onhighlight={handleHighlight}
|
onhighlight={handleHighlight}
|
||||||
onsetinput={handleSetInput}
|
onsetinput={handleSetInput}
|
||||||
onctrlclick={handleCtrlClick}
|
onctrlclick={handleCtrlClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.yolo-toggle-row {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yolo-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yolo-marker {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
25
src/routes/admin/+layout.server.ts
Normal file
25
src/routes/admin/+layout.server.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
13
src/routes/admin/+layout.svelte
Normal file
13
src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-layout">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-layout {
|
||||||
|
min-height: calc(100vh - 8rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
747
src/routes/admin/+page.svelte
Normal file
747
src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Unit, Group, UnitsData } from '$lib/types';
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────────
|
||||||
|
let unitsData = $state<UnitsData>({ units: [], groups: [] });
|
||||||
|
let selectedItem = $state<{ type: 'unit' | 'group'; id: string } | null>(null);
|
||||||
|
let checkedUnitIds = $state<Set<string>>(new Set());
|
||||||
|
let moveTargetGroupId = $state<string>('');
|
||||||
|
let expandedGroups = $state<Set<string>>(new Set());
|
||||||
|
let deleteGroupTarget = $state<Group | null>(null);
|
||||||
|
let deleteGroupAction = $state<'reassign' | 'orphan'>('reassign');
|
||||||
|
let deleteGroupTargetId = $state<string>('');
|
||||||
|
let deleteGroupToBaseAction = $state<'recalculate' | 'reset' | 'keep'>('keep');
|
||||||
|
let formError = $state<string | null>(null);
|
||||||
|
let formSuccess = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Unit form state
|
||||||
|
let unitForm = $state<Partial<Unit>>({});
|
||||||
|
let unitFormNew = $state(false);
|
||||||
|
|
||||||
|
// Group form state
|
||||||
|
let groupForm = $state<Partial<Group>>({});
|
||||||
|
let groupFormNew = $state(false);
|
||||||
|
|
||||||
|
// ── Load data on mount ───────────────────────────────────────────────────────
|
||||||
|
async function loadData() {
|
||||||
|
const res = await fetch('/admin/api/units');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const units: Unit[] = await res.json();
|
||||||
|
const gres = await fetch('/admin/api/groups');
|
||||||
|
const groups: Group[] = gres.ok ? await gres.json() : [];
|
||||||
|
unitsData = { units, groups };
|
||||||
|
// Initialize expanded state
|
||||||
|
for (const g of groups) {
|
||||||
|
expandedGroups.add(g.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
$effect(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────────────
|
||||||
|
let unitsByGroup = $derived.by(() => {
|
||||||
|
const map = new Map<string | null, Unit[]>();
|
||||||
|
for (const u of unitsData.units) {
|
||||||
|
const key = u.group ?? null;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(u);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBaseUnit(group: Group): Unit | undefined {
|
||||||
|
return unitsData.units.find((u) => u.id === group.baseUnitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection ────────────────────────────────────────────────────────────────
|
||||||
|
function selectUnit(unit: Unit) {
|
||||||
|
selectedItem = { type: 'unit', id: unit.id };
|
||||||
|
unitForm = { ...unit };
|
||||||
|
unitFormNew = false;
|
||||||
|
groupFormNew = false;
|
||||||
|
formError = null;
|
||||||
|
formSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(group: Group) {
|
||||||
|
selectedItem = { type: 'group', id: group.id };
|
||||||
|
groupForm = { ...group };
|
||||||
|
groupFormNew = false;
|
||||||
|
unitFormNew = false;
|
||||||
|
formError = null;
|
||||||
|
formSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewUnit(groupId?: string) {
|
||||||
|
selectedItem = null;
|
||||||
|
unitForm = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
|
||||||
|
unitFormNew = true;
|
||||||
|
groupFormNew = false;
|
||||||
|
formError = null;
|
||||||
|
formSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewGroup() {
|
||||||
|
selectedItem = null;
|
||||||
|
groupForm = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
|
||||||
|
groupFormNew = true;
|
||||||
|
unitFormNew = false;
|
||||||
|
formError = null;
|
||||||
|
formSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit form submit ─────────────────────────────────────────────────────────
|
||||||
|
async function saveUnit() {
|
||||||
|
formError = null;
|
||||||
|
formSuccess = null;
|
||||||
|
try {
|
||||||
|
let res: Response;
|
||||||
|
if (unitFormNew) {
|
||||||
|
res = await fetch('/admin/api/units', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(unitForm)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`/admin/api/units/${selectedItem?.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(unitForm)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
formError = (err as Record<string, string>).error ?? 'Save failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
|
||||||
|
if (unitFormNew) {
|
||||||
|
const created = (await res.json()) as Unit;
|
||||||
|
unitFormNew = false;
|
||||||
|
selectedItem = { type: 'unit', id: created.id ?? '' };
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
formError = 'Network error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUnit() {
|
||||||
|
if (!selectedItem || selectedItem.type !== 'unit') return;
|
||||||
|
if (!confirm(`Delete unit "${unitForm.label}"?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
formError = (err as Record<string, string>).error ?? 'Delete failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedItem = null;
|
||||||
|
unitForm = {};
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
formError = 'Network error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group form submit ────────────────────────────────────────────────────────
|
||||||
|
async function saveGroup() {
|
||||||
|
formError = null;
|
||||||
|
formSuccess = null;
|
||||||
|
try {
|
||||||
|
let res: Response;
|
||||||
|
if (groupFormNew) {
|
||||||
|
res = await fetch('/admin/api/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(groupForm)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`/admin/api/groups/${selectedItem?.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(groupForm)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
formError = (err as Record<string, string>).error ?? 'Save failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
|
||||||
|
if (groupFormNew) {
|
||||||
|
const created = (await res.json()) as Group;
|
||||||
|
groupFormNew = false;
|
||||||
|
selectedItem = { type: 'group', id: created.id ?? '' };
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
formError = 'Network error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete group modal ───────────────────────────────────────────────────────
|
||||||
|
function openDeleteGroup(group: Group) {
|
||||||
|
deleteGroupTarget = group;
|
||||||
|
deleteGroupAction = 'reassign';
|
||||||
|
deleteGroupTargetId = '';
|
||||||
|
deleteGroupToBaseAction = 'keep';
|
||||||
|
formError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteGroup() {
|
||||||
|
deleteGroupTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteGroup() {
|
||||||
|
if (!deleteGroupTarget) return;
|
||||||
|
const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length;
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
if (unitsCount === 0) {
|
||||||
|
body = { action: 'orphan' };
|
||||||
|
} else if (deleteGroupAction === 'reassign') {
|
||||||
|
body = {
|
||||||
|
action: 'reassign',
|
||||||
|
targetGroupId: deleteGroupTargetId,
|
||||||
|
toBaseAction: deleteGroupToBaseAction
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
body = { action: 'orphan' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/admin/api/groups/${deleteGroupTarget.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
formError = (err as Record<string, string>).error ?? 'Delete failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteGroupTarget = null;
|
||||||
|
selectedItem = null;
|
||||||
|
groupForm = {};
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
formError = 'Network error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select move ────────────────────────────────────────────────────────
|
||||||
|
async function moveCheckedUnits() {
|
||||||
|
if (!moveTargetGroupId || checkedUnitIds.size === 0) return;
|
||||||
|
for (const unitId of checkedUnitIds) {
|
||||||
|
await fetch(`/admin/api/units/${unitId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ group: moveTargetGroupId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checkedUnitIds = new Set();
|
||||||
|
moveTargetGroupId = '';
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheck(unitId: string) {
|
||||||
|
const next = new Set(checkedUnitIds);
|
||||||
|
if (next.has(unitId)) next.delete(unitId);
|
||||||
|
else next.add(unitId);
|
||||||
|
checkedUnitIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live toBase hint ─────────────────────────────────────────────────────────
|
||||||
|
let toBaseHint = $derived.by(() => {
|
||||||
|
const g = unitsData.groups.find((gr) => gr.id === unitForm.group);
|
||||||
|
if (!g) return null;
|
||||||
|
const base = unitsData.units.find((u) => u.id === g.baseUnitId);
|
||||||
|
const sym = unitForm.symbol ?? '?';
|
||||||
|
const tb = unitForm.toBase ?? 1;
|
||||||
|
const baseSymbol = base?.symbol ?? 'base units';
|
||||||
|
return `1 ${sym} = ${tb} ${baseSymbol}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/admin/api/auth/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Humor Units — Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h2>Admin</h2>
|
||||||
|
<button class="outline secondary logout-btn" onclick={logout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if checkedUnitIds.size > 0}
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<span>{checkedUnitIds.size} unit{checkedUnitIds.size === 1 ? '' : 's'} selected</span>
|
||||||
|
<select bind:value={moveTargetGroupId}>
|
||||||
|
<option value="">Move to group…</option>
|
||||||
|
{#each unitsData.groups as g}
|
||||||
|
<option value={g.id}>{g.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button onclick={moveCheckedUnits} disabled={!moveTargetGroupId}>Move</button>
|
||||||
|
<button class="outline secondary" onclick={() => (checkedUnitIds = new Set())}>Clear</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="admin-panels">
|
||||||
|
<!-- Left panel: tree -->
|
||||||
|
<aside class="admin-tree">
|
||||||
|
{#each unitsData.groups as group (group.id)}
|
||||||
|
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
|
||||||
|
<div class="tree-group">
|
||||||
|
<div class="tree-group-header">
|
||||||
|
<button
|
||||||
|
class="tree-expand plain"
|
||||||
|
onclick={() => {
|
||||||
|
const next = new Set(expandedGroups);
|
||||||
|
if (next.has(group.id)) next.delete(group.id);
|
||||||
|
else next.add(group.id);
|
||||||
|
expandedGroups = next;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedGroups.has(group.id) ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
<button class="tree-group-label plain" onclick={() => selectGroup(group)}>
|
||||||
|
<strong>{group.label}</strong>
|
||||||
|
<small class="muted">{groupUnits.length} unit{groupUnits.length === 1 ? '' : 's'}</small>
|
||||||
|
</button>
|
||||||
|
<div class="tree-group-actions">
|
||||||
|
<button class="icon-btn plain" title="Edit group" onclick={() => selectGroup(group)}>✏️</button>
|
||||||
|
<button class="icon-btn plain" title="Delete group" onclick={() => openDeleteGroup(group)}>🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if expandedGroups.has(group.id)}
|
||||||
|
<div class="tree-units">
|
||||||
|
{#each groupUnits as unit (unit.id)}
|
||||||
|
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedUnitIds.has(unit.id)}
|
||||||
|
onchange={() => toggleCheck(unit.id)}
|
||||||
|
/>
|
||||||
|
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}>
|
||||||
|
{unit.label}
|
||||||
|
<span class="muted">({unit.symbol})</span>
|
||||||
|
{#if unit.id === group.baseUnitId}
|
||||||
|
<span class="base-badge" title="Base unit">★</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn plain" title="Edit" onclick={() => selectUnit(unit)}>✏️</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button class="add-unit-btn plain" onclick={() => startNewUnit(group.id)}>
|
||||||
|
+ Add Unit to {group.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Ungrouped section -->
|
||||||
|
{#if (unitsByGroup.get(null) ?? []).length > 0}
|
||||||
|
{@const orphaned = unitsByGroup.get(null) ?? []}
|
||||||
|
<div class="tree-group">
|
||||||
|
<div class="tree-group-header">
|
||||||
|
<span class="tree-group-label"><strong>Ungrouped</strong> <small class="muted">{orphaned.length}</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="tree-units">
|
||||||
|
{#each orphaned as unit (unit.id)}
|
||||||
|
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedUnitIds.has(unit.id)}
|
||||||
|
onchange={() => toggleCheck(unit.id)}
|
||||||
|
/>
|
||||||
|
<button class="tree-unit-label plain" onclick={() => selectUnit(unit)}>
|
||||||
|
{unit.label} <span class="muted">({unit.symbol})</span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn plain" onclick={() => selectUnit(unit)}>✏️</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="tree-footer">
|
||||||
|
<button onclick={() => startNewUnit()}>+ Add Unit</button>
|
||||||
|
<button class="outline" onclick={startNewGroup}>+ Add Group</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Right panel: form -->
|
||||||
|
<section class="admin-form-panel">
|
||||||
|
{#if formError}
|
||||||
|
<p class="form-error" role="alert">{formError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if formSuccess}
|
||||||
|
<p class="form-success" role="status">{formSuccess}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if (selectedItem?.type === 'unit' || unitFormNew)}
|
||||||
|
<!-- Unit form -->
|
||||||
|
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
|
||||||
|
<label>
|
||||||
|
ID {#if !unitFormNew}<small>(readonly)</small>{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={unitForm.id}
|
||||||
|
readonly={!unitFormNew}
|
||||||
|
placeholder="e.g. my-unit"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Label
|
||||||
|
<input type="text" bind:value={unitForm.label} placeholder="e.g. My Unit" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Label (plural)
|
||||||
|
<input type="text" bind:value={unitForm.labelPlural} placeholder="e.g. My Units" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Symbol
|
||||||
|
<input type="text" bind:value={unitForm.symbol} placeholder="e.g. mu" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Group
|
||||||
|
<select bind:value={unitForm.group}>
|
||||||
|
<option value={null}>Ungrouped</option>
|
||||||
|
{#each unitsData.groups as g}
|
||||||
|
<option value={g.id}>{g.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To Base
|
||||||
|
<input type="number" bind:value={unitForm.toBase} min="0.000001" step="any" />
|
||||||
|
{#if toBaseHint}
|
||||||
|
<small class="tobase-hint">{toBaseHint}</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button onclick={saveUnit}>{unitFormNew ? 'Create Unit' : 'Save Unit'}</button>
|
||||||
|
{#if !unitFormNew}
|
||||||
|
<button class="outline secondary" onclick={deleteUnit}>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if (selectedItem?.type === 'group' || groupFormNew)}
|
||||||
|
<!-- Group form -->
|
||||||
|
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
|
||||||
|
<label>
|
||||||
|
ID {#if !groupFormNew}<small>(readonly)</small>{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={groupForm.id}
|
||||||
|
readonly={!groupFormNew}
|
||||||
|
placeholder="e.g. my-group"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Label
|
||||||
|
<input type="text" bind:value={groupForm.label} placeholder="e.g. My Group" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Base Unit
|
||||||
|
<small>Changing the base unit will auto-recalculate all toBase values in this group.</small>
|
||||||
|
<select bind:value={groupForm.baseUnitId}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each unitsData.units.filter((u) => u.group === groupForm.id) as u}
|
||||||
|
<option value={u.id}>{u.label} ({u.symbol})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To Universal
|
||||||
|
<small title="Relative scale for cross-group YOLO conversions. Arbitrary — make it up.">
|
||||||
|
Relative scale for cross-group conversions.
|
||||||
|
</small>
|
||||||
|
<input type="number" bind:value={groupForm.toUniversal} min="0.000001" step="any" />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button onclick={saveGroup}>{groupFormNew ? 'Create Group' : 'Save Group'}</button>
|
||||||
|
{#if !groupFormNew}
|
||||||
|
<button
|
||||||
|
class="outline secondary"
|
||||||
|
onclick={() => {
|
||||||
|
const g = unitsData.groups.find((gr) => gr.id === selectedItem?.id);
|
||||||
|
if (g) openDeleteGroup(g);
|
||||||
|
}}
|
||||||
|
>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<p class="select-hint">Select a unit or group to edit</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete group modal -->
|
||||||
|
{#if deleteGroupTarget}
|
||||||
|
{@const unitsCount = (unitsByGroup.get(deleteGroupTarget.id) ?? []).length}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="modal-backdrop" onclick={closeDeleteGroup}>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<article class="modal-card" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Delete "{deleteGroupTarget.label}"</h3>
|
||||||
|
|
||||||
|
{#if unitsCount === 0}
|
||||||
|
<p>This group has no units. Delete it?</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
deleteGroupAction = 'orphan';
|
||||||
|
confirmDeleteGroup();
|
||||||
|
}}
|
||||||
|
>Delete Group</button>
|
||||||
|
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p>This group contains <strong>{unitsCount}</strong> unit{unitsCount === 1 ? '' : 's'}.</p>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>What to do with these units?</legend>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={deleteGroupAction} value="reassign" />
|
||||||
|
Reassign to another group
|
||||||
|
</label>
|
||||||
|
{#if deleteGroupAction === 'reassign'}
|
||||||
|
<select bind:value={deleteGroupTargetId} style="margin: 0.5rem 0;">
|
||||||
|
<option value="">Select target group…</option>
|
||||||
|
{#each unitsData.groups.filter((g) => g.id !== deleteGroupTarget?.id) as g}
|
||||||
|
<option value={g.id}>{g.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div>
|
||||||
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="keep" /> Keep toBase values</label>
|
||||||
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="recalculate" /> Recalculate (scale by toUniversal ratio)</label>
|
||||||
|
<label><input type="radio" bind:group={deleteGroupToBaseAction} value="reset" /> Reset to 1.0</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={deleteGroupAction} value="orphan" />
|
||||||
|
Delete Group, Keep Units (Orphan)
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
onclick={confirmDeleteGroup}
|
||||||
|
disabled={deleteGroupAction === 'reassign' && !deleteGroupTargetId}
|
||||||
|
>
|
||||||
|
{deleteGroupAction === 'reassign' ? 'Reassign & Delete Group' : 'Delete Group, Keep Units'}
|
||||||
|
</button>
|
||||||
|
<button class="outline secondary" onclick={closeDeleteGroup}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border: 1px solid var(--pico-card-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.admin-panels { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tree {
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border: 1px solid var(--pico-card-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group { margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
.tree-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-units {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-unit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-unit-row.selected {
|
||||||
|
background: var(--pico-primary-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-unit-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plain {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.base-badge {
|
||||||
|
color: var(--pico-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-unit-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pico-primary);
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--pico-card-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-panel {
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border: 1px solid var(--pico-card-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-hint {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--pico-del-color, #e74c3c);
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-success {
|
||||||
|
color: var(--pico-ins-color, #2ecc71);
|
||||||
|
background: rgba(46, 204, 113, 0.1);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tobase-hint {
|
||||||
|
display: block;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
src/routes/admin/api/auth/login/+server.ts
Normal file
51
src/routes/admin/api/auth/login/+server.ts
Normal file
@@ -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 <password>');
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
20
src/routes/admin/api/auth/logout/+server.ts
Normal file
20
src/routes/admin/api/auth/logout/+server.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
64
src/routes/admin/api/groups/+server.ts
Normal file
64
src/routes/admin/api/groups/+server.ts
Normal file
@@ -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<Group>;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
111
src/routes/admin/api/groups/[id]/+server.ts
Normal file
111
src/routes/admin/api/groups/[id]/+server.ts
Normal file
@@ -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<Group>;
|
||||||
|
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 });
|
||||||
|
};
|
||||||
59
src/routes/admin/api/units/+server.ts
Normal file
59
src/routes/admin/api/units/+server.ts
Normal file
@@ -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<Unit>;
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
};
|
||||||
61
src/routes/admin/api/units/[id]/+server.ts
Normal file
61
src/routes/admin/api/units/[id]/+server.ts
Normal file
@@ -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<Unit>;
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
};
|
||||||
85
src/routes/admin/login/+page.svelte
Normal file
85
src/routes/admin/login/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = null;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await goto('/admin');
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
error = data.error ?? 'Incorrect password';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Network error — please try again';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Humor Units — Admin Login</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="login-wrapper">
|
||||||
|
<article class="login-card">
|
||||||
|
<h2>Admin Login</h2>
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if error}
|
||||||
|
<p class="login-error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" disabled={loading || !password}>
|
||||||
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--pico-del-color, #e74c3c);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/routes/api/units/+server.ts
Normal file
12
src/routes/api/units/+server.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter()
|
||||||
fallback: '404.html'
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
vitePlugin: {
|
vitePlugin: {
|
||||||
dynamicCompileOptions: ({ filename }) =>
|
dynamicCompileOptions: ({ filename }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user