First frontend

This commit is contained in:
2026-04-30 21:37:21 +02:00
parent c3e98af5b6
commit ac48e03404
16 changed files with 1764 additions and 5 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.next/
data/*.db data/*.db
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
tsconfigPath: "./tsconfig.next.json",
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:3000/api/:path*",
},
{
source: "/health",
destination: "http://localhost:3000/health",
},
];
},
};
export default nextConfig;
+860
View File
@@ -12,12 +12,18 @@
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"lucide-react": "^1.14.0",
"next": "^16.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"zod": "^4.4.1" "zod": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
@@ -29,6 +35,15 @@
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
"dev": true "dev": true
}, },
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild-kit/core-utils": { "node_modules/@esbuild-kit/core-utils": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
@@ -856,6 +871,580 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@next/env": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -934,6 +1523,24 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true "dev": true
}, },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -984,6 +1591,17 @@
} }
] ]
}, },
"node_modules/baseline-browser-mapping": {
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
"integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "12.9.0", "version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
@@ -1102,11 +1720,35 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/caniuse-lite": {
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1143,6 +1785,12 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1730,6 +2378,14 @@
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
}, },
"node_modules/lucide-react": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1809,6 +2465,23 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": { "node_modules/napi-build-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -1822,6 +2495,58 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/next": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"dependencies": {
"@next/env": "16.2.4",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.4",
"@next/swc-darwin-x64": "16.2.4",
"@next/swc-linux-arm64-gnu": "16.2.4",
"@next/swc-linux-arm64-musl": "16.2.4",
"@next/swc-linux-x64-gnu": "16.2.4",
"@next/swc-linux-x64-musl": "16.2.4",
"@next/swc-win32-arm64-msvc": "16.2.4",
"@next/swc-win32-x64-msvc": "16.2.4",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.89.0", "version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
@@ -1880,6 +2605,38 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1977,6 +2734,25 @@
"rc": "cli.js" "rc": "cli.js"
} }
}, },
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.5"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -2038,6 +2814,11 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -2097,6 +2878,50 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2217,6 +3042,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": { "node_modules/source-map-support": {
"version": "0.5.21", "version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
@@ -2251,6 +3084,28 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -2285,6 +3140,11 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+12 -2
View File
@@ -4,8 +4,12 @@
"description": "", "description": "",
"main": "dist/server/index.js", "main": "dist/server/index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/server/index.ts", "dev": "npm run dev:api",
"build": "tsc -p tsconfig.json", "dev:api": "tsx watch src/server/index.ts",
"dev:web": "next dev -p 3001",
"build": "npm run build:api",
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts", "test": "tsx --test tests/power-calculation.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts", "test:watch": "tsx --watch --test tests/power-calculation.test.ts",
@@ -19,12 +23,18 @@
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"lucide-react": "^1.14.0",
"next": "^16.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"zod": "^4.4.1" "zod": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
+279
View File
@@ -0,0 +1,279 @@
:root {
color-scheme: light;
--bg: #f4f6f8;
--band: #ffffff;
--line: #d7dde5;
--line-strong: #b9c3d0;
--text: #17212f;
--muted: #647084;
--accent: #0f766e;
--accent-dark: #115e59;
--warn-bg: #fff4e5;
--warn-line: #f3b562;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
}
button,
input,
select {
font: inherit;
}
.workspace {
min-height: 100vh;
padding: 20px;
display: grid;
gap: 14px;
}
.topbar,
.toolbarBand,
.entryBand,
.tableBand {
background: var(--band);
border: 1px solid var(--line);
}
.topbar {
min-height: 82px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 19px;
}
.iconButton,
.primaryButton {
border: 1px solid var(--accent);
background: var(--accent);
color: #ffffff;
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
}
.iconButton {
width: 38px;
border-radius: 6px;
}
.primaryButton {
border-radius: 6px;
padding: 0 12px;
white-space: nowrap;
}
.primaryButton:hover,
.iconButton:hover {
background: var(--accent-dark);
}
.primaryButton:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.alert {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--warn-line);
background: var(--warn-bg);
}
.toolbarBand {
display: grid;
grid-template-columns: minmax(360px, 1.25fr) minmax(320px, 1fr);
gap: 14px;
padding: 14px;
}
.projectForm,
.consumerForm {
display: grid;
gap: 10px;
align-items: end;
}
.projectForm {
grid-template-columns: minmax(180px, 1fr) minmax(220px, 1fr) auto;
}
.consumerForm {
grid-template-columns: minmax(210px, 1.6fr) minmax(150px, 1fr) 90px 140px 150px 110px 110px 100px minmax(180px, 1.4fr) auto;
}
label {
min-width: 0;
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
}
input,
select {
width: 100%;
min-height: 36px;
border: 1px solid var(--line-strong);
border-radius: 6px;
padding: 6px 9px;
color: var(--text);
background: #ffffff;
}
.summaryStrip {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
border: 1px solid var(--line);
}
.summaryStrip div {
padding: 11px 12px;
border-right: 1px solid var(--line);
display: grid;
gap: 4px;
}
.summaryStrip div:last-child {
border-right: 0;
}
.summaryStrip span,
.statusPill {
color: var(--muted);
font-size: 12px;
}
.summaryStrip strong {
font-size: 18px;
}
.entryBand,
.tableBand {
padding: 14px;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
}
.statusPill,
.nameCell {
display: inline-flex;
align-items: center;
gap: 7px;
}
.statusPill {
min-height: 30px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 9px;
}
.tableScroll {
overflow-x: auto;
border: 1px solid var(--line);
}
table {
width: 100%;
min-width: 1020px;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: middle;
}
th {
background: #edf1f5;
color: #344054;
font-weight: 700;
}
tbody tr:hover {
background: #f8fafc;
}
.emptyState {
height: 92px;
color: var(--muted);
text-align: center;
}
@media (max-width: 1180px) {
.toolbarBand,
.projectForm,
.consumerForm {
grid-template-columns: 1fr 1fr;
}
.summaryStrip,
.wideField,
.consumerForm .primaryButton {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
.workspace {
padding: 10px;
}
.topbar,
.tableHeader {
align-items: flex-start;
flex-direction: column;
}
.toolbarBand,
.projectForm,
.consumerForm,
.summaryStrip {
grid-template-columns: 1fr;
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Leistungsbilanz",
description: "Leistungsbilanz fuer elektrische Verbraucher",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PowerBalanceWorkspace } from "../frontend/components/power-balance-workspace";
export default function Home() {
return <PowerBalanceWorkspace />;
}
@@ -0,0 +1,17 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../client.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export class DistributionBoardRepository {
async listByProject(projectId: string) {
return db.select().from(distributionBoards).where(eq(distributionBoards.projectId, projectId));
}
async create(projectId: string, name: string) {
const id = crypto.randomUUID();
const board = { id, projectId, name };
await db.insert(distributionBoards).values(board);
return board;
}
}
@@ -0,0 +1,386 @@
"use client";
import { Activity, FolderPlus, Plus, RefreshCw, Zap } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
createConsumer,
createDistributionBoard,
createProject,
listConsumers,
listDistributionBoards,
listProjects,
} from "../utils/api";
import type {
ConsumerWithCalculatedValues,
CreateConsumerInput,
DistributionBoardDto,
ProjectDto,
} from "../types";
const initialConsumerForm = {
name: "",
category: "",
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
note: "",
};
type ConsumerForm = typeof initialConsumerForm;
function toOptionalNumber(value: string) {
if (value.trim() === "") {
return undefined;
}
return Number(value);
}
function formatNumber(value: number | undefined, digits = 2) {
if (value === undefined || Number.isNaN(value)) {
return "-";
}
return new Intl.NumberFormat("de-DE", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
}).format(value);
}
export function PowerBalanceWorkspace() {
const [projects, setProjects] = useState<ProjectDto[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState("");
const [distributionBoards, setDistributionBoards] = useState<DistributionBoardDto[]>([]);
const [selectedBoardId, setSelectedBoardId] = useState("");
const [consumers, setConsumers] = useState<ConsumerWithCalculatedValues[]>([]);
const [projectName, setProjectName] = useState("");
const [boardName, setBoardName] = useState("");
const [consumerForm, setConsumerForm] = useState<ConsumerForm>(initialConsumerForm);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedProject = projects.find((project) => project.id === selectedProjectId);
const selectedBoard = distributionBoards.find((board) => board.id === selectedBoardId);
const visibleConsumers = selectedBoardId
? consumers.filter((consumer) => consumer.distributionBoardId === selectedBoardId)
: consumers;
const totals = useMemo(
() =>
visibleConsumers.reduce(
(sum, consumer) => ({
installedPowerKw: sum.installedPowerKw + consumer.installedPowerKw,
demandPowerKw: sum.demandPowerKw + consumer.demandPowerKw,
}),
{ installedPowerKw: 0, demandPowerKw: 0 }
),
[visibleConsumers]
);
async function refreshProjects() {
setError(null);
const loadedProjects = await listProjects();
setProjects(loadedProjects);
setSelectedProjectId((current) => current || loadedProjects[0]?.id || "");
}
async function refreshConsumers(projectId: string) {
if (!projectId) {
setConsumers([]);
return;
}
setError(null);
setConsumers(await listConsumers(projectId));
}
async function refreshDistributionBoards(projectId: string) {
if (!projectId) {
setDistributionBoards([]);
setSelectedBoardId("");
return;
}
setError(null);
const boards = await listDistributionBoards(projectId);
setDistributionBoards(boards);
setSelectedBoardId((current) => (boards.some((board) => board.id === current) ? current : boards[0]?.id || ""));
}
useEffect(() => {
refreshProjects()
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Projekte konnten nicht geladen werden."))
.finally(() => setIsLoading(false));
}, []);
useEffect(() => {
refreshDistributionBoards(selectedProjectId).catch((err: unknown) =>
setError(err instanceof Error ? err.message : "Verteilungen konnten nicht geladen werden.")
);
refreshConsumers(selectedProjectId).catch((err: unknown) =>
setError(err instanceof Error ? err.message : "Verbraucher konnten nicht geladen werden.")
);
}, [selectedProjectId]);
async function handleCreateProject(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = projectName.trim();
if (!name) {
return;
}
setIsSaving(true);
setError(null);
try {
const project = await createProject(name);
setProjects((current) => [...current, project]);
setSelectedProjectId(project.id);
setProjectName("");
} catch (err) {
setError(err instanceof Error ? err.message : "Projekt konnte nicht angelegt werden.");
} finally {
setIsSaving(false);
}
}
async function handleCreateDistributionBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = boardName.trim();
if (!selectedProjectId || !name) {
return;
}
setIsSaving(true);
setError(null);
try {
const board = await createDistributionBoard(selectedProjectId, name);
setDistributionBoards((current) => [...current, board]);
setSelectedBoardId(board.id);
setBoardName("");
} catch (err) {
setError(err instanceof Error ? err.message : "Verteilung konnte nicht angelegt werden.");
} finally {
setIsSaving(false);
}
}
async function handleCreateConsumer(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedProjectId || !consumerForm.name.trim()) {
return;
}
const input: CreateConsumerInput = {
projectId: selectedProjectId,
distributionBoardId: selectedBoardId || undefined,
name: consumerForm.name.trim(),
category: consumerForm.category.trim() || undefined,
quantity: Number(consumerForm.quantity),
installedPowerPerUnitKw: Number(consumerForm.installedPowerPerUnitKw),
demandFactor: Number(consumerForm.demandFactor),
voltageV: toOptionalNumber(consumerForm.voltageV),
phaseCount: consumerForm.phaseCount === "3" ? 3 : 1,
powerFactor: toOptionalNumber(consumerForm.powerFactor),
note: consumerForm.note.trim() || undefined,
};
setIsSaving(true);
setError(null);
try {
const created = await createConsumer(input);
setConsumers((current) => [...current, created]);
setConsumerForm(initialConsumerForm);
} catch (err) {
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht angelegt werden.");
} finally {
setIsSaving(false);
}
}
function updateConsumerForm(field: keyof ConsumerForm, value: string) {
setConsumerForm((current) => ({ ...current, [field]: value }));
}
return (
<main className="workspace">
<header className="topbar">
<div>
<p className="eyebrow">TGA / ELT Planung</p>
<h1>Leistungsbilanz</h1>
</div>
<button className="iconButton" type="button" onClick={() => refreshConsumers(selectedProjectId)} title="Aktualisieren">
<RefreshCw size={18} />
</button>
</header>
{error ? <p className="alert">{error}</p> : null}
<section className="toolbarBand">
<form className="projectForm" onSubmit={handleCreateProject}>
<label>
Projekt
<select value={selectedProjectId} onChange={(event) => setSelectedProjectId(event.target.value)}>
<option value="">Kein Projekt</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<label>
Neues Projekt
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} placeholder="z. B. BV Neubau" />
</label>
<button className="primaryButton" type="submit" disabled={isSaving}>
<FolderPlus size={17} />
Anlegen
</button>
</form>
<form className="boardForm" onSubmit={handleCreateDistributionBoard}>
<label>
Verteilung
<select value={selectedBoardId} onChange={(event) => setSelectedBoardId(event.target.value)}>
<option value="">Alle Verteilungen</option>
{distributionBoards.map((board) => (
<option key={board.id} value={board.id}>
{board.name}
</option>
))}
</select>
</label>
<label>
Neue Verteilung
<input value={boardName} onChange={(event) => setBoardName(event.target.value)} placeholder="z. B. UV-01" />
</label>
<button className="primaryButton" type="submit" disabled={!selectedProjectId || isSaving}>
<Plus size={17} />
Anlegen
</button>
</form>
<div className="summaryStrip" aria-label="Summen">
<div>
<span>Verbraucher</span>
<strong>{visibleConsumers.length}</strong>
</div>
<div>
<span>Installierte Leistung</span>
<strong>{formatNumber(totals.installedPowerKw)} kW</strong>
</div>
<div>
<span>Berechnete Leistung</span>
<strong>{formatNumber(totals.demandPowerKw)} kW</strong>
</div>
</div>
</section>
<section className="entryBand">
<form className="consumerForm" onSubmit={handleCreateConsumer}>
<label>
Verbraucher
<input value={consumerForm.name} onChange={(event) => updateConsumerForm("name", event.target.value)} placeholder="z. B. Steckdosen Büro" />
</label>
<label>
Kategorie
<input value={consumerForm.category} onChange={(event) => updateConsumerForm("category", event.target.value)} placeholder="Allgemein" />
</label>
<label>
Anzahl
<input min="0" type="number" value={consumerForm.quantity} onChange={(event) => updateConsumerForm("quantity", event.target.value)} />
</label>
<label>
Leistung je Stück [kW]
<input min="0" step="0.01" type="number" value={consumerForm.installedPowerPerUnitKw} onChange={(event) => updateConsumerForm("installedPowerPerUnitKw", event.target.value)} />
</label>
<label>
Gleichzeitigkeitsfaktor
<input min="0" max="1" step="0.01" type="number" value={consumerForm.demandFactor} onChange={(event) => updateConsumerForm("demandFactor", event.target.value)} />
</label>
<label>
Spannung [V]
<input min="1" type="number" value={consumerForm.voltageV} onChange={(event) => updateConsumerForm("voltageV", event.target.value)} />
</label>
<label>
Phasen
<select value={consumerForm.phaseCount} onChange={(event) => updateConsumerForm("phaseCount", event.target.value)}>
<option value="1">1-phasig</option>
<option value="3">3-phasig</option>
</select>
</label>
<label>
cos phi
<input min="0" max="1" step="0.01" type="number" value={consumerForm.powerFactor} onChange={(event) => updateConsumerForm("powerFactor", event.target.value)} />
</label>
<label className="wideField">
Bemerkung
<input value={consumerForm.note} onChange={(event) => updateConsumerForm("note", event.target.value)} />
</label>
<button className="primaryButton" type="submit" disabled={!selectedProjectId || isSaving}>
<Plus size={17} />
Hinzufügen
</button>
</form>
</section>
<section className="tableBand">
<div className="tableHeader">
<div>
<p className="eyebrow">Aktuelles Projekt</p>
<h2>{selectedProject?.name || "Noch kein Projekt ausgewählt"}</h2>
<p className="subline">{selectedBoard ? `Verteilung: ${selectedBoard.name}` : "Alle Verteilungen"}</p>
</div>
<div className="statusPill">
<Activity size={16} />
{isLoading ? "Lädt" : "Bereit"}
</div>
</div>
<div className="tableScroll">
<table>
<thead>
<tr>
<th>Verbraucher</th>
<th>Kategorie</th>
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>Installierte Leistung [kW]</th>
<th>GZF</th>
<th>Berechnete Leistung [kW]</th>
<th>Strom [A]</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
{visibleConsumers.map((consumer) => (
<tr key={consumer.id}>
<td className="nameCell">
<Zap size={15} />
{consumer.name}
</td>
<td>{consumer.category || "-"}</td>
<td>{consumer.quantity}</td>
<td>{formatNumber(consumer.installedPowerPerUnitKw)}</td>
<td>{formatNumber(consumer.installedPowerKw)}</td>
<td>{formatNumber(consumer.demandFactor, 2)}</td>
<td>{formatNumber(consumer.demandPowerKw)}</td>
<td>{formatNumber(consumer.currentA)}</td>
<td>{consumer.note || "-"}</td>
</tr>
))}
{!visibleConsumers.length ? (
<tr>
<td colSpan={9} className="emptyState">
Lege eine Verteilung an oder erfasse den ersten Verbraucher.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</main>
);
}
+42
View File
@@ -0,0 +1,42 @@
export interface ProjectDto {
id: string;
name: string;
}
export interface ConsumerWithCalculatedValues {
id: string;
projectId: string;
distributionBoardId?: string | null;
name: string;
category?: string;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
voltageV?: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
installedPowerKw: number;
demandPowerKw: number;
currentA?: number;
}
export interface DistributionBoardDto {
id: string;
projectId: string;
name: string;
}
export interface CreateConsumerInput {
projectId: string;
distributionBoardId?: string;
name: string;
category?: string;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
voltageV?: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
}
+56
View File
@@ -0,0 +1,56 @@
import type {
ConsumerWithCalculatedValues,
CreateConsumerInput,
DistributionBoardDto,
ProjectDto,
} from "../types";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
if (!response.ok) {
const details = await response.text();
throw new Error(details || `Request failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
export function listProjects() {
return request<ProjectDto[]>("/api/projects");
}
export function createProject(name: string) {
return request<ProjectDto>("/api/projects", {
method: "POST",
body: JSON.stringify({ name }),
});
}
export function listDistributionBoards(projectId: string) {
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
}
export function createDistributionBoard(projectId: string, name: string) {
return request<DistributionBoardDto>(`/api/projects/${projectId}/distribution-boards`, {
method: "POST",
body: JSON.stringify({ name }),
});
}
export function listConsumers(projectId: string) {
return request<ConsumerWithCalculatedValues[]>(`/api/consumers/projects/${projectId}`);
}
export function createConsumer(input: CreateConsumerInput) {
return request<ConsumerWithCalculatedValues>("/api/consumers", {
method: "POST",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,30 @@
import type { Request, Response } from "express";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
const distributionBoardRepository = new DistributionBoardRepository();
export async function listDistributionBoardsByProject(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const result = await distributionBoardRepository.listByProject(projectId);
return res.json(result);
}
export async function createDistributionBoard(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createDistributionBoardSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const board = await distributionBoardRepository.create(projectId, parsed.data.name);
return res.status(201).json(board);
}
+6 -1
View File
@@ -1,8 +1,13 @@
import { Router } from "express"; import { Router } from "express";
import { createProject, listProjects } from "../controllers/project.controller.js"; import { createProject, listProjects } from "../controllers/project.controller.js";
import {
createDistributionBoard,
listDistributionBoardsByProject,
} from "../controllers/distribution-board.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
projectRouter.get("/", listProjects); projectRouter.get("/", listProjects);
projectRouter.post("/", createProject); projectRouter.post("/", createProject);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
+5 -1
View File
@@ -18,6 +18,10 @@ export const createProjectSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
}); });
export const createDistributionBoardSchema = z.object({
name: z.string().min(1),
});
export type CreateConsumerInput = z.infer<typeof createConsumerSchema>; export type CreateConsumerInput = z.infer<typeof createConsumerSchema>;
export type CreateProjectInput = z.infer<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type CreateDistributionBoardInput = z.infer<typeof createDistributionBoardSchema>;
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
"incremental": true,
"isolatedModules": true,
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"rootDir": ".",
"plugins": [{ "name": "next" }]
},
"include": [
"next-env.d.ts",
"src/app/**/*.ts",
"src/app/**/*.tsx",
"src/frontend/**/*.ts",
"src/frontend/**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "dist"]
}