feat & styling

This commit is contained in:
axel 2025-04-09 09:57:55 +02:00
parent c753171846
commit c419d57754
37 changed files with 1344 additions and 310 deletions

5
.gitignore vendored
View File

@ -24,4 +24,7 @@ vite.config.ts.timestamp-*
# Have empty data folder ready to go
/data/*
!/data/.gitkeep
!/data/.gitkeep
# Generated prisma client
generated/prisma/

439
package-lock.json generated
View File

@ -8,23 +8,29 @@
"name": "my-app",
"version": "0.0.1",
"dependencies": {
"@prisma/client": "^6.5.0",
"@types/wake_on_lan": "^0.0.33",
"bcryptjs": "^3.0.2",
"drizzle-orm": "^0.41.0",
"lowdb": "^7.0.1",
"nanoid": "^5.1.5"
"nanoid": "^5.1.5",
"wake_on_lan": "^1.0.0"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.17",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prisma": "^6.5.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.3",
"typescript": "^5.0.0",
"unplugin-icons": "^22.1.0",
"vite": "^6.2.5"
}
},
@ -42,6 +48,30 @@
"node": ">=6.0.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.0.0.tgz",
"integrity": "sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"package-manager-detector": "^0.2.8",
"tinyexec": "^0.3.2"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@antfu/utils": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz",
"integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
@ -467,6 +497,40 @@
"node": ">=18"
}
},
"node_modules/@iconify-json/tabler": {
"version": "1.2.17",
"resolved": "https://registry.npmjs.org/@iconify-json/tabler/-/tabler-1.2.17.tgz",
"integrity": "sha512-Jfk20IC/n7UOQQSXM600BUhAwEfg8KU1dNUF+kg4eRhbET5w1Ktyax7CDx8Z8y0H6+J/8//AXpJOEgG8YoP8rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz",
"integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.0.0",
"@antfu/utils": "^8.1.0",
"@iconify/types": "^2.0.0",
"debug": "^4.4.0",
"globals": "^15.14.0",
"kolorist": "^1.8.0",
"local-pkg": "^1.0.0",
"mlly": "^1.7.4"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@ -704,6 +768,89 @@
"dev": true,
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.5.0.tgz",
"integrity": "sha512-M6w1Ql/BeiGoZmhMdAZUXHu5sz5HubyVcKukbLs3l0ELcQb8hTUJxtGEChhv4SVJ0QJlwtLnwOLgIRQhpsm9dw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.5.0.tgz",
"integrity": "sha512-sOH/2Go9Zer67DNFLZk6pYOHj+rumSb0VILgltkoxOjYnlLqUpHPAN826vnx8HigqnOCxj9LRhT6U7uLiIIWgw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"esbuild": ">=0.12 <1",
"esbuild-register": "3.6.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.5.0.tgz",
"integrity": "sha512-fc/nusYBlJMzDmDepdUtH9aBsJrda2JNErP9AzuHbgUEQY0/9zQYZdNlXmKoIWENtio+qarPNe/+DQtrX5kMcQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.5.0.tgz",
"integrity": "sha512-FVPQYHgOllJklN9DUyujXvh3hFJCY0NX86sDmBErLvoZjy2OXGiZ5FNf3J/C4/RZZmCypZBYpBKEhx7b7rEsdw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.5.0",
"@prisma/engines-version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60",
"@prisma/fetch-engine": "6.5.0",
"@prisma/get-platform": "6.5.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60.tgz",
"integrity": "sha512-iK3EmiVGFDCmXjSpdsKGNqy9hOdLnvYBrJB61far/oP03hlIxrb04OWmDjNTwtmZ3UZdA5MCvI+f+3k2jPTflQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.5.0.tgz",
"integrity": "sha512-3LhYA+FXP6pqY8FLHCjewyE8pGXXJ7BxZw2rhPq+CZAhvflVzq4K8Qly3OrmOkn6wGlz79nyLQdknyCG2HBTuA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.5.0",
"@prisma/engines-version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60",
"@prisma/get-platform": "6.5.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.5.0.tgz",
"integrity": "sha512-xYcvyJwNMg2eDptBYFqFLUCfgi+wZLcj6HDMsj0Qw0irvauG4IKmkbywnqwok0B+k+W+p+jThM2DKTSmoPCkzw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.5.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
@ -1335,12 +1482,19 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/wake_on_lan": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/wake_on_lan/-/wake_on_lan-0.0.33.tgz",
"integrity": "sha512-kUDV5jTVq9iAeWfZfBhWFnLUXl5ggbCpJEYiXwMmisuQaHbfpZuo2DbOPHio9ZBMcqC45vuBAn/YKr1QRqelog==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -1420,6 +1574,13 @@
"node": ">=6"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@ -1639,7 +1800,7 @@
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -1676,6 +1837,19 @@
"@esbuild/win32-x64": "0.25.2"
}
},
"node_modules/esbuild-register": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"esbuild": ">=0.12 <1"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@ -1693,6 +1867,13 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/exsolve": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz",
"integrity": "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz",
@ -1797,6 +1978,19 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -1864,6 +2058,13 @@
"node": ">=6"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/libsql": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.4.tgz",
@ -2145,6 +2346,24 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/local-pkg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz",
"integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.0.1",
"quansync": "^0.2.8"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@ -2177,6 +2396,47 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"pathe": "^2.0.1",
"pkg-types": "^1.3.0",
"ufo": "^1.5.4"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -2263,6 +2523,23 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/package-manager-detector": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz",
"integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"quansync": "^0.2.7"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2270,6 +2547,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/pkg-types": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz",
"integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.1",
"exsolve": "^1.0.1",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -2345,6 +2634,35 @@
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/prisma": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.5.0.tgz",
"integrity": "sha512-yUGXmWqv5F4PByMSNbYFxke/WbnyTLjnJ5bKr8fLkcnY7U5rU9rUTh/+Fja+gOrRxEgtCbCtca94IeITj4j/pg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.5.0",
"@prisma/engines": "6.5.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=18.18"
},
"optionalDependencies": {
"fsevents": "2.3.3"
},
"peerDependencies": {
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/promise-limit": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
@ -2353,6 +2671,23 @@
"optional": true,
"peer": true
},
"node_modules/quansync": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
"integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -2569,6 +2904,13 @@
"node": ">=6"
}
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@ -2603,7 +2945,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -2613,13 +2955,77 @@
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unplugin": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.2.2.tgz",
"integrity": "sha512-Qp+iiD+qCRnUek+nDoYvtWX7tfnYyXsrOnJ452FRTgOyKmTM7TUJ3l+PLPJOOWPTUyKISKp4isC5JJPSXUjGgw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"dependencies": {
"acorn": "^8.14.1",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unplugin-icons": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-22.1.0.tgz",
"integrity": "sha512-ect2ZNtk1Zgwb0NVHd0C1IDW/MV+Jk/xaq4t8o6rYdVS3+L660ZdD5kTSQZvsgdwCvquRw+/wYn75hsweRjoIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.0.0",
"@iconify/utils": "^2.3.0",
"debug": "^4.4.0",
"local-pkg": "^1.0.0",
"unplugin": "^2.2.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@svgr/core": ">=7.0.0",
"@svgx/core": "^1.0.1",
"@vue/compiler-sfc": "^3.0.2 || ^2.7.0",
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0",
"vue-template-compiler": "^2.6.12",
"vue-template-es2015-compiler": "^1.9.0"
},
"peerDependenciesMeta": {
"@svgr/core": {
"optional": true
},
"@svgx/core": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"svelte": {
"optional": true
},
"vue-template-compiler": {
"optional": true
},
"vue-template-es2015-compiler": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "6.2.5",
@ -2712,6 +3118,18 @@
}
}
},
"node_modules/wake_on_lan": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wake_on_lan/-/wake_on_lan-1.0.0.tgz",
"integrity": "sha512-0QSpxny0QmsssshI6kePj6cobQPK+i8r5shfj58ZfQIUH9fUTyAaYPqZO3W/Ai7mN4vQVdTdsSGIr20M81UL6Q==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"wake": "wake"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@ -2723,6 +3141,13 @@
"node": ">= 8"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",

View File

@ -14,24 +14,30 @@
"lint": "prettier --check ."
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.17",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prisma": "^6.5.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.3",
"typescript": "^5.0.0",
"unplugin-icons": "^22.1.0",
"vite": "^6.2.5"
},
"dependencies": {
"@prisma/client": "^6.5.0",
"@types/wake_on_lan": "^0.0.33",
"bcryptjs": "^3.0.2",
"drizzle-orm": "^0.41.0",
"lowdb": "^7.0.1",
"nanoid": "^5.1.5"
"nanoid": "^5.1.5",
"wake_on_lan": "^1.0.0"
},
"overrides": {
"@sveltejs/kit": {

View File

@ -0,0 +1,77 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"admin" BOOLEAN NOT NULL DEFAULT false
);
-- CreateTable
CREATE TABLE "Group" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Device" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"mac" TEXT NOT NULL,
"broadcast" TEXT NOT NULL DEFAULT '255.255.255.255',
"port" INTEGER NOT NULL DEFAULT 9,
"packets" INTEGER NOT NULL DEFAULT 3
);
-- CreateTable
CREATE TABLE "_GroupToUser" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_GroupToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Group" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_GroupToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "_DeviceToUser" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_DeviceToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Device" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_DeviceToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "_DeviceToGroup" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_DeviceToGroup_A_fkey" FOREIGN KEY ("A") REFERENCES "Device" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_DeviceToGroup_B_fkey" FOREIGN KEY ("B") REFERENCES "Group" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_name_key" ON "User"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Device_name_key" ON "Device"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Device_mac_key" ON "Device"("mac");
-- CreateIndex
CREATE UNIQUE INDEX "_GroupToUser_AB_unique" ON "_GroupToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_GroupToUser_B_index" ON "_GroupToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_DeviceToUser_AB_unique" ON "_DeviceToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_DeviceToUser_B_index" ON "_DeviceToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_DeviceToGroup_AB_unique" ON "_DeviceToGroup"("A", "B");
-- CreateIndex
CREATE INDEX "_DeviceToGroup_B_index" ON "_DeviceToGroup"("B");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

38
prisma/schema.prisma Normal file
View File

@ -0,0 +1,38 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../data/db.sqlite"
}
model User {
id Int @id @default(autoincrement())
name String @unique
password String
admin Boolean @default(false)
groups Group[]
devices Device[]
}
model Group {
id Int @id @default(autoincrement())
name String @unique
users User[]
devices Device[]
}
model Device {
id Int @id @default(autoincrement())
name String @unique
mac String @unique
broadcast String @default("255.255.255.255")
port Int @default(9)
packets Int @default(3)
users User[]
groups Group[]
}

View File

@ -1 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@import 'tailwindcss';
* {
font-family: "Inter", sans-serif;
}

1
src/app.d.ts vendored
View File

@ -1,6 +1,7 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { Guard } from "$lib/server/guard";
import 'unplugin-icons/types/svelte';
// for information about these interfaces
declare global {

View File

@ -1,4 +1,4 @@
import { db } from "$lib/server/db/db";
import { prisma } from "$lib/server/db/db";
import type { ServerInit } from "@sveltejs/kit";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
@ -7,20 +7,17 @@ import { Guard } from "$lib/server/guard";
import { getUserFromSession } from "$lib/server/sessions";
export const init: ServerInit = async () => {
const anyUser = db.data.users.at(0);
const anyUser = await prisma.user.findFirst();
if (!anyUser) {
const pass = nanoid(12);
const pass = nanoid();
await db.update(({ users }) => {
users.push({
id: nanoid(),
await prisma.user.create({
data: {
name: "admin",
password: bcrypt.hashSync(pass, 10),
admin: true,
groups: [],
permissions: {}
});
admin: true
}
});
console.log(`default admin password: ${pass}`);

View File

@ -0,0 +1,51 @@
<script lang="ts">
import Button from "../ui/Button.svelte";
import IconPlus from "~icons/tabler/plus";
import IconMinus from "~icons/tabler/minus";
let {
label,
sublabel = "",
name,
data
}: {
label: string,
sublabel?: string,
name: string,
data: {
value: string,
name: string,
selected: boolean
}[]
} = $props();
let selectData = $state(data);
$effect(() => {
console.log(selectData);
})
</script>
<div class="flex gap-x-10 gap-y-3 flex-wrap mb-5">
<label for={name} class="block w-1/3 min-w-[300px]">
<p>{label}</p>
{#if sublabel}
<p class="text-sm text-neutral-400">{sublabel}</p>
{/if}
</label>
<select multiple name={name} class="hidden">
{#each selectData.filter(d => d.selected) as el}
<option value={el.value} selected></option>
{/each}
</select>
<div class="flex gap-2 flex-wrap border border-neutral-200 rounded p-4 shadow w-fit h-fit max-w-[300px] max-h-[200px] overflow-y-scroll">
{#if selectData.length == 0}
<p class="text-sm text-neutral-400">No data</p>
{:else}
{#each selectData as el}
<Button Icon={el.selected ? IconMinus : IconPlus} type="button" extra="!px-2 !py-1 !text-xs !rounded-sm" onclick={() => {el.selected = !el.selected}} inverted={!el.selected}>{el.name}</Button>
{/each}
{/if}
</div>
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
let {
label,
sublabel = "",
name,
defaultValue,
...others
} = $props();
</script>
<div class="flex gap-x-10 gap-y-3 flex-wrap mb-5 items-center">
<label for={name} class="block w-1/3 min-w-[300px]">
<p>{label}</p>
{#if sublabel}
<p class="text-sm text-neutral-400">{sublabel}</p>
{/if}
</label>
<input id={name} name={name} type="text" defaultValue={defaultValue} {...others}
class="block h-fit rounded border border-gray-300 shadow-sm px-4 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import Button from "../ui/Button.svelte";
import IconCheck from "~icons/tabler/check";
import IconX from "~icons/tabler/x";
let {
label,
sublabel = "",
name,
checked,
options = ["Yes", "No"],
...others
} = $props();
</script>
<div class="flex gap-x-10 gap-y-3 flex-wrap mb-5 items-center">
<label for={name} class="block w-1/3 min-w-[300px]">
<p>{label}</p>
{#if sublabel.length > 0}
<p class="text-sm text-neutral-400">{sublabel}</p>
{/if}
</label>
<input class="hidden" type="checkbox" id={name} name={name} defaultChecked={checked}/>
<Button Icon={checked ? IconCheck : IconX} type="button" inverted={!checked} {...others} onclick={() => checked = !checked}>{checked ? options[0] : options[1]}</Button>
</div>

View File

@ -0,0 +1,19 @@
<script lang="ts">
let {
title,
subtitle,
actionsSnippet
} = $props();
</script>
<div class="flex gap-10 justify-between items-center rounded border border-neutral-200 py-4 px-6 shadow">
<div>
<p class="font-medium">{title}</p>
<p class="text-neutral-400">{subtitle}</p>
</div>
<div class="flex gap-2">
{#if actionsSnippet}
{@render actionsSnippet()}
{/if}
</div>
</div>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { fade } from "svelte/transition";
import Button from "../ui/Button.svelte";
import HorizontalSpacer from "../ui/HorizontalSpacer.svelte";
import ResourcePageHeader from "./ResourcePageHeader.svelte";
import IconEclamationCircle from "~icons/tabler/exclamation-circle";
import IconChevronLeft from "~icons/tabler/chevron-left";
let {
heading,
children,
error = null,
} = $props();
$effect(() => {
if (error) {
setTimeout(() => error = null, 5000)
}
});
</script>
<div class="flex flex-col h-full">
<ResourcePageHeader title={heading}>
<Button Icon={IconChevronLeft} onclick={() => history.back()} inverted>Back</Button>
</ResourcePageHeader>
<HorizontalSpacer/>
<div class="text-sm text-neutral-700 flex-1 overflow-scroll">
{@render children()}
</div>
{#if error}
<div transition:fade>
<Button Icon={IconEclamationCircle} type="button" color="red" onclick={() => error = null}>{error} (Click to dismiss)</Button>
</div>
{/if}
</div>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import HorizontalSpacer from "../ui/HorizontalSpacer.svelte";
import ResourcePageHeader from "./ResourcePageHeader.svelte";
let {
heading,
actionSnippet,
contentSnippet
} = $props();
</script>
<div class="flex flex-col h-full">
<ResourcePageHeader title={heading}>
{@render actionSnippet()}
</ResourcePageHeader>
<HorizontalSpacer/>
<div class="text-sm text-neutral-700 flex-1 overflow-scroll">
{@render contentSnippet()}
</div>
</div>

View File

@ -0,0 +1,8 @@
<script lang="ts">
let { title, children } = $props();
</script>
<div class="flex justify-between items-center">
<h1 class="text-xl font-semibold">{title}</h1>
{@render children()}
</div>

View File

@ -0,0 +1,73 @@
<script lang="ts">
import type { ClassValue, HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
type Props = {
a?: boolean,
color?: "neutral" | "red" | "green",
inverted?: boolean,
Icon?: any,
extra?: ClassValue,
children?: any
} & HTMLButtonAttributes & HTMLAnchorAttributes;
let {
a,
color,
inverted,
Icon,
children,
extra,
...others
}: Props = $props();
const baseClasses = "block flex items-center text-sm px-4 py-2 transition cursor-pointer rounded box-border";
let colorClasses = $state("");
let fullClasses = $derived(baseClasses + " " + extra + " " + colorClasses);
$effect(() => {
if (!inverted) {
switch (color) {
case "red":
colorClasses = "bg-red-500 hover:bg-red-600 text-white border border-transparent";
break;
case "green":
colorClasses = "bg-emerald-500 hover:bg-emerald-600 text-white border border-transparent";
break;
default:
colorClasses= "bg-neutral-800 hover:bg-neutral-600 text-white border border-transparent";
}
} else {
switch (color) {
case "red":
colorClasses = "bg-white hover:bg-red-100 text-red-500 border border-red-500";
break;
case "green":
colorClasses = "bg-white hover:bg-emerald-100 text-emerald-500 border border-emerald-500";
break;
default:
colorClasses = "bg-white hover:bg-neutral-100 text-neutral-800 border border-neutral-800";
}
}
});
</script>
{#if a}
<a class={fullClasses} {...others}>
{#if Icon}
<Icon class={children ? "mr-2" : ""}></Icon>
{/if}
{#if children}
{@render children()}
{/if}
</a>
{:else}
<button class={fullClasses} {...others}>
{#if Icon}
<Icon class={children ? "mr-2" : ""}></Icon>
{/if}
{#if children}
{@render children()}
{/if}
</button>
{/if}

View File

@ -0,0 +1,19 @@
<script lang="ts">
import IconChevronRight from "~icons/tabler/chevron-right";
import { slide } from "svelte/transition";
let { children, label } = $props();
let expanded = $state(false);
</script>
<div class="mb-4">
<button type="button" class="flex items-center gap-2 cursor-pointer" onclick={() => expanded = !expanded}>
<IconChevronRight class="{expanded ? "rotate-90" : ""} transition-all duration-300"/>
<h1 class="text-xs uppercase font-light">{label}</h1>
</button>
{#if expanded}
<div transition:slide={{duration: 300}} class="mt-4">
{@render children()}
</div>
{/if}
</div>

View File

@ -0,0 +1 @@
<div class="w-full h-px bg-neutral-200 my-4"></div>

View File

@ -2,6 +2,9 @@ import { JSONFilePreset } from "lowdb/node";
import type { User } from "./types/user";
import type { Device } from "./types/device";
import type { Group } from "./types/group";
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
type Data = {
users: User[],
@ -15,4 +18,4 @@ const defaultData: Data = {
devices: [],
};
export const db = await JSONFilePreset<Data>("./data/db.json", defaultData);
//export const db = await JSONFilePreset<Data>("./data/db.json", defaultData);

View File

@ -1,5 +1,5 @@
import { fail, redirect } from "@sveltejs/kit";
import type { User } from "./db/types/user";
import { redirect } from "@sveltejs/kit";
import type { User } from "@prisma/client";
export class Guard {
private readonly user?: User;
@ -45,7 +45,7 @@ export class Guard {
return false;
}
public getUser(): User {
public getUser() {
return this.user!;
}
}

View File

@ -1,9 +1,9 @@
import { nanoid } from "nanoid";
import { db } from "./db/db";
import { prisma } from "./db/db";
import type { User } from "./db/types/user";
type SessionData = {
userId: string,
userId: number,
userAgent: string,
};
@ -16,7 +16,7 @@ export function createSession(data: SessionData) {
return token;
};
export async function getUserFromSession(sessionId?: string): Promise<User | undefined> {
export async function getUserFromSession(sessionId?: string) {
if (!sessionId) {
return undefined;
}
@ -29,7 +29,11 @@ export async function getUserFromSession(sessionId?: string): Promise<User | und
// what in the nested fuck is this shit
// I thought ORMs made it easier but they just make queries more ridiculous
const user = db.data.users.find(user => user.id === data.userId);
const user = await prisma.user.findUnique({
where: {
id: data.userId
}
}) ?? undefined;
return user;
};

View File

@ -1,15 +1,60 @@
<script lang="ts">
import IconHome from "~icons/tabler/home";
import IconUsers from "~icons/tabler/users";
import IconUsersGroup from "~icons/tabler/users-group";
import IconLogout2 from "~icons/tabler/logout2";
let { children, data } = $props();
const links = [
{
name: 'Devices',
href: '/dashboard/devices',
icon: IconHome,
admin: false,
},
{
name: 'Users',
href: '/dashboard/users',
icon: IconUsers,
admin: true,
},
{
name: 'Groups',
href: '/dashboard/groups',
icon: IconUsersGroup,
admin: true,
},
];
</script>
<p>logged in as {data.user.name}</p>
<a href="/dashboard/devices">devices</a>
{#if data.user.admin}
<a href="/dashboard/users">users</a>
<a href="/dashboard/groups">groups</a>
{/if}
<a href="/logout">logout</a>
<div class="flex min-h-screen overflow-hidden">
<!-- Sidebar -->
<div id="sidebar" class="h-screen w-64 bg-neutral-100 transform translate-x-0 transition-transform duration-300 ease-in-out z-40 text-neutral-700 text-sm border-r border-neutral-200">
<div class="p-4 flex flex-col h-full">
<ul class="flex-1">
{#each links as link}
{#if data.user.admin || !link.admin}
<li class="mb-2">
<a class="block flex items-center p-2 bg-transparent hover:bg-neutral-200 transition-all cursor-pointer rounded" href={link.href}>
<link.icon class="mr-2"/>
{link.name}
</a>
</li>
{/if}
{/each}
</ul>
<br><br>
{@render children()}
<div class="p-2 flex w-full justify-between items-center">
<p>Signed in as <span class="underline underline-offset-4">{data.user.name}</span></p>
<a data-sveltekit-preload-data="tap" href="/logout" class="block p-2 bg-transparent hover:bg-neutral-200 transition-all cursor-pointer rounded"><IconLogout2/></a>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 p-6 bg-white w-full h-screen">
{@render children()}
</div>
</div>

View File

@ -1,17 +1,31 @@
import { db } from "$lib/server/db/db";
import { prisma } from "$lib/server/db/db";
import type { ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ locals: { guard} }) => {
const user = guard.requiresAuth().orRedirects().getUser();
if (user.admin) {
return {
devices: await prisma.device.findMany(),
}
}
const userDevices = await prisma.user.findUnique({
where: {
id: user.id
},
include: {
devices: true,
groups: {
include: {
devices: true
}
}
}
});
return {
devices: user.admin ? db.data.devices :
db.data.devices.filter(device =>
Object.keys(user.permissions).includes(device.id) ||
user.groups.some(groupId => {
const group = db.data.groups.find(group => group.id === groupId);
return group && Object.keys(group.permissions).includes(device.id)
})
),
devices: userDevices == null ? [] :
userDevices.devices.concat(userDevices.groups.flatMap(group => group.devices)),
}
};

View File

@ -1,18 +1,40 @@
<script lang="ts">
import IconPlus from "~icons/tabler/plus";
import IconEdit from "~icons/tabler/edit";
import IconPlayerPlay from "~icons/tabler/player-play";
import Button from "$lib/components/ui/Button.svelte";
import ResourceListPage from "$lib/components/resources/ResourceListPage.svelte";
import ResourceCard from "$lib/components/resources/ResourceCard.svelte";
import { enhance } from "$app/forms";
let { data } = $props();
</script>
{#if data.user.admin}
<a href="/dashboard/devices/new">new device</a>
{/if}
<ResourceListPage heading="Devices">
{#snippet actionSnippet()}
{#if data.user.admin}
<Button Icon={IconPlus} a href="/dashboard/devices/new">Add Device</Button>
{/if}
{/snippet}
{#if data.devices.length === 0}
<p>No devices found</p>
{:else}
<p>Devices:</p>
<ul>
{#each data.devices as device}
<li>{device.name} {#if data.user.admin}- <a href="/dashboard/devices/{device.id}">edit</a>{/if}</li>
{/each}
</ul>
{/if}
{#snippet contentSnippet()}
{#if data.devices.length === 0}
<p>No devices found</p>
{:else}
<div class="flex gap-4 flex-wrap">
{#each data.devices as device}
<ResourceCard title={device.name} subtitle={device.mac}>
{#snippet actionsSnippet()}
{#if data.user.admin}
<Button Icon={IconEdit} a href="/dashboard/devices/{device.id}" extra="!p-2"/>
{/if}
<form method="POST" action="/dashboard/devices/{device.id}?/wake" use:enhance>
<Button Icon={IconPlayerPlay} type="submit" color="green" extra="!p-2"></Button>
</form>
{/snippet}
</ResourceCard>
{/each}
</div>
{/if}
{/snippet}
</ResourceListPage>

View File

@ -1,12 +1,15 @@
import { db } from '$lib/server/db/db';
import { getUserFromSession } from '$lib/server/sessions';
import { prisma } from '$lib/server/db/db';
import { fail, redirect, type ServerLoad } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import { wake } from 'wake_on_lan';
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
const device = db.data.devices.find(device => device.id === params.slug);
const device = await prisma.device.findUnique({
where: {
id: parseInt(params.slug!) || -1,
}
});
if (!device && params.slug !== "new") {
redirect(302, "/dashboard/devices");
@ -26,42 +29,49 @@ export const actions = {
const form = await request.formData();
const name = form.get("name")?.toString();
const mac = form.get("mac")?.toString();
const ip = form.get("ip")?.toString();
const broadcast = form.get("broadcast")?.toString();
const port = form.get("port")?.toString();
const packets = form.get("packets")?.toString();
if (!name || !mac || !ip || !port || !packets) {
if (!name || !mac) {
// TODO better validation
return {
error: "MISSING_FIELDS"
}
}
if (params.slug === "new") {
await db.update(({ devices }) => {
devices.push({
id: nanoid(),
name,
mac,
ip,
port: parseInt(port),
packets: parseInt(packets)
try {
if (params.slug === "new") {
await prisma.device.create({
data: {
name,
mac,
broadcast,
port: port ? parseInt(port) : undefined,
packets: packets ? parseInt(packets) : undefined
}
});
} else {
await prisma.device.update({
where: {
id: parseInt(params.slug)
},
data: {
name,
mac,
broadcast,
port: port ? parseInt(port) : undefined,
packets: packets ? parseInt(packets) : undefined
}
});
});
} else {
let device = db.data.devices.find(device => device.id === params.slug);
if (!device) {
return;
}
device.name = name;
device.mac = mac;
device.ip = ip;
device.port = parseInt(port);
device.packets = parseInt(packets);
await db.write();
} catch (e: any) {
if (e.code === "P2002") {
return fail(409, { error: "This name or this MAC adress is already in use. Please make sure they are unique." });
} else {
console.error(e);
return fail(500, { error: "DATABASE_ERROR" });
}
}
redirect(302, "/dashboard/devices");
@ -71,16 +81,61 @@ export const actions = {
return fail(403);
}
db.data.devices = db.data.devices.filter(device => device.id !== params.slug);
db.data.users.forEach(user => {
delete user.permissions[params.slug];
await prisma.device.delete({
where: {
id: parseInt(params.slug)
}
});
db.data.groups.forEach(group => {
delete group.permissions[params.slug];
});
await db.write();
redirect(302, "/dashboard/devices");
},
wake: async ({ params, locals: { guard } }) => {
console.log("Trying to wake " + params.slug);
guard = guard.requiresAuth();
if (guard.isFailed()) {
console.log("Failed guard");
return fail(403);
}
const userDevices = await prisma.user.findUnique({
where: {
id: guard.getUser().id
},
include: {
devices: true,
groups: {
include: {
devices: true
}
}
}
});
if (!userDevices) {
console.log("Failed to find user devices");
return fail(403);
}
let deviceId = parseInt(params.slug);
if (isNaN(deviceId)) {
return fail(400);
}
const device = userDevices.devices.find(d => d.id === deviceId) ?? userDevices.groups.flatMap(g => g.devices).find(d => d.id === deviceId);
if (!device) {
return fail(404);
}
console.log("Trying to wake " + device.name);
wake(device.mac, {
address: device.broadcast,
port: device.port,
num_packets: device.packets
}, () => {});
}
}

View File

@ -1,35 +1,51 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Collapsible from "$lib/components/ui/Collapsible.svelte";
import InputText from "$lib/components/forms/InputText.svelte";
import ResourceEditPage from "$lib/components/resources/ResourceEditPage.svelte";
import IconDeviceFloppy from "~icons/tabler/device-floppy";
import IconTrash from "~icons/tabler/trash";
let { form, data } = $props();
</script>
<form method="POST" action="?/update">
<label>
Name
<input name="name" type="text" defaultValue={data.device ? data.device.name : "New Device"}>
</label>
<label>
Mac Address
<input name="mac" type="text" defaultValue={data.device ? data.device.mac : "00:00:00:00:00:00"}>
</label>
<label>
Broadcast Ip Address
<input name="ip" type="text" defaultValue={data.device ? data.device.ip : "255.255.255.255"}>
</label>
<label>
Port
<input name="port" type="text" defaultValue={data.device ? data.device.port : "9"}>
</label>
<label>
Amount of packets
<input name="packets" type="text" defaultValue={data.device ? data.device.packets : "3"}>
</label>
<button>{data.device ? "Update" : "Create"}</button>
{#if data.device}
<button formaction="?/delete">Delete</button>
{/if}
</form>
{#if form?.error}
<p>Could not create device: {form.error}</p>
{/if}
<ResourceEditPage heading={data.device ? "Editing Device - " + data.device.name : "New Device"} error={form?.error}>
<form method="POST" action="?/update" use:enhance>
<InputText
label="Device Name"
sublabel="How the device will appear in the dashboard"
name="name"
defaultValue={data.device ? data.device.name : "New Device"}/>
<InputText
label="MAC Address"
sublabel="Address used for the Wake-On-Lan packets"
name="mac"
defaultValue={data.device ? data.device.mac : "00:00:00:00:00:00"}/>
<Collapsible label="Advanced">
<InputText
label="Broadcast IP Address"
name="broadcast"
defaultValue={data.device ? data.device.broadcast : "255.255.255.255"}/>
<InputText
label="Broadcast Port"
name="port"
defaultValue={data.device ? data.device.port : "9"}/>
<InputText
label="Packets Quantity"
name="packets"
defaultValue={data.device ? data.device.packets : "3"}/>
</Collapsible>
<div class="flex gap-5 items-center">
<Button Icon={IconDeviceFloppy}>{data.device ? "Update" : "Create"}</Button>
{#if data.device}
<Button Icon={IconTrash} color="red" inverted formaction="?/delete">Delete</Button>
{/if}
</div>
</form>
</ResourceEditPage>

View File

@ -1,10 +1,15 @@
import { db } from "$lib/server/db/db";
import { prisma } from "$lib/server/db/db";
import { type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ locals: { guard } }) => {
guard.requiresAdmin().orRedirects();
return {
groups: db.data.groups,
groups: await prisma.group.findMany({
include: {
users: true,
devices: true,
}
}),
}
};

View File

@ -1,16 +1,33 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
import ResourceCard from "$lib/components/resources/ResourceCard.svelte";
import ResourceListPage from "$lib/components/resources/ResourceListPage.svelte";
import IconPlus from "~icons/tabler/plus";
import IconEdit from "~icons/tabler/edit";
let { data } = $props();
</script>
<a href="/dashboard/groups/new">new group</a>
<ResourceListPage heading="Groups">
{#snippet actionSnippet()}
<Button Icon={IconPlus} a href="/dashboard/groups/new">Add Group</Button>
{/snippet}
{#if data.groups.length === 0}
<p>No groups found</p>
{:else}
<p>Groups:</p>
<ul>
{#each data.groups as group}
<li>{group.name} - <a href="/dashboard/groups/{group.id}">edit</a></li>
{/each}
</ul>
{/if}
{#snippet contentSnippet()}
{#if data.groups.length === 0}
<p>No groups found</p>
{:else}
<div class="flex gap-4 flex-wrap">
{#each data.groups as group}
<ResourceCard title={group.name} subtitle="{group.users.length} users, {group.devices.length} devices">
{#snippet actionsSnippet()}
{#if data.user.admin}
<Button Icon={IconEdit} a href="/dashboard/groups/{group.id}" extra="!p-2"/>
{/if}
{/snippet}
</ResourceCard>
{/each}
</div>
{/if}
{/snippet}
</ResourceListPage>

View File

@ -1,21 +1,23 @@
import { db } from "$lib/server/db/db";
import type { Permission } from "$lib/server/db/types/permission";
import type { User } from "$lib/server/db/types/user";
import { getUserFromSession } from "$lib/server/sessions";
import { prisma } from "$lib/server/db/db";
import { fail, redirect, type Actions, type ServerLoad } from "@sveltejs/kit";
import { nanoid } from "nanoid";
export const load: ServerLoad = async ({ locals: { guard },params }) => {
guard.requiresAdmin().orRedirects();
const group = db.data.groups.find(group => group.id === params.slug);
const group = await prisma.group.findUnique({
where: {
id: parseInt(params.slug!) || -1,
}, include: {
devices: true
}
});
if (!group && params.slug !== "new") {
redirect(302, "/dashboard/groups");
}
return {
devices: db.data.devices,
devices: await prisma.device.findMany(),
group,
}
};
@ -28,20 +30,7 @@ export const actions: Actions = {
const form = await request.formData();
const name = form.get("name")?.toString();
let permissions: { [key: string]: Permission } = {};
for (let deviceId of form.getAll("canSee")) {
if (db.data.devices.find(device => device.id === deviceId)) {
permissions[deviceId.toString()] = { wake: false };
}
}
for (let deviceId of form.getAll("canWake")) {
if (db.data.devices.find(device => device.id === deviceId)) {
permissions[deviceId.toString()] = { wake: true };
}
}
const devices = form.getAll("devices").map(d => ({ id: parseInt(d.toString()) }));
if (!name) {
// TODO better validation
@ -51,23 +40,25 @@ export const actions: Actions = {
}
if (params.slug === "new") {
await db.update(({ groups }) => {
groups.push({
id: nanoid(),
await prisma.group.create({
data: {
name,
permissions,
});
devices: {
connect: devices
}
}
});
} else {
await db.update(({ groups }) => {
const group = groups.find(group => group.id === params.slug);
if (!group) {
return;
await prisma.group.update({
where: {
id: parseInt(params.slug!) || -1,
},
data: {
name,
devices: {
set: devices
}
}
group.name = name;
group.permissions = permissions;
});
}
@ -79,14 +70,12 @@ export const actions: Actions = {
return fail(403);
}
db.data.groups = db.data.groups.filter(group => group.id !== params.slug);
db.data.users = db.data.users.map(user => {
user.groups = user.groups.filter(groupId => groupId !== params.slug);
return user;
await prisma.group.delete({
where: {
id: parseInt(params.slug!) || -1,
}
});
await db.write();
redirect(302, "/dashboard/groups");
}
};

View File

@ -1,34 +1,37 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import InputSelect from "$lib/components/forms/InputSelect.svelte";
import InputText from "$lib/components/forms/InputText.svelte";
import ResourceEditPage from "$lib/components/resources/ResourceEditPage.svelte";
import IconDeviceFloppy from "~icons/tabler/device-floppy";
import IconTrash from "~icons/tabler/trash";
let { form, data } = $props();
</script>
<form method="POST" action="?/update">
<label>
Name
<input name="name" type="text" defaultValue={data.group ? data.group.name : "New Group"}>
</label>
<label>
Can see
<select multiple name="canSee">
{#each data.devices as device}
<option value={device.id} selected={data.group?.permissions[device.id] ? true : false}>{device.name}</option>
{/each}
</select>
</label>
<label>
Can wake
<select multiple name="canWake">
{#each data.devices as device}
<option value={device.id} selected={data.group?.permissions[device.id]?.wake ? true : false}>{device.name}</option>
{/each}
</select>
</label>
<button>{data.group ? "Update" : "Create"}</button>
{#if data.group}
<button formaction="?/delete">Delete</button>
{/if}
</form>
<ResourceEditPage heading={data.group ? "Editing Group - " + data.group.name : "New Group"} error={form?.error}>
<form method="POST" action="?/update" use:enhance>
<InputText
label="Group Name"
sublabel="How the group will appear in the dashboard"
name="name"
defaultValue={data.group ? data.group.name : "New Group"}/>
{#if form?.error}
<p>Could not {data.group ? "update" : "create"} group: {form.error}</p>
{/if}
<InputSelect
label="Devices"
sublabel="Devices that users who are part of this group will be ble to wake"
name="devices"
data={data.devices.map(d => ({
value: d.id.toString(),
name: d.name,
selected: data.group?.devices.find(gd => gd.id === d.id) ? true : false }))}/>
<div class="flex gap-5 items-center">
<Button Icon={IconDeviceFloppy}>{data.group ? "Update" : "Create"}</Button>
{#if data.group}
<Button Icon={IconTrash} color="red" inverted formaction="?/delete">Delete</Button>
{/if}
</div>
</form>
</ResourceEditPage>

View File

@ -1,10 +1,15 @@
import { db } from "$lib/server/db/db";
import { prisma } from "$lib/server/db/db";
import type { ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ locals: { guard } }) => {
guard.requiresAdmin().orRedirects();
return {
users: db.data.users,
users: await prisma.user.findMany({
include: {
groups: true,
devices: true
}
}),
}
};

View File

@ -1,16 +1,27 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
import ResourceCard from "$lib/components/resources/ResourceCard.svelte";
import ResourceListPage from "$lib/components/resources/ResourceListPage.svelte";
import IconPlus from "~icons/tabler/plus";
import IconEdit from "~icons/tabler/edit";
let { data } = $props();
</script>
<a href="/dashboard/users/new">new user</a>
<ResourceListPage heading="Users">
{#snippet actionSnippet()}
<Button Icon={IconPlus} a href="/dashboard/users/new">Add User</Button>
{/snippet}
{#if data.users.length === 0}
<p>No users found</p>
{:else}
<p>Users:</p>
<ul>
{#each data.users as user}
<li>{user.name} - <a href="/dashboard/users/{user.id}">edit</a></li>
{/each}
</ul>
{/if}
{#snippet contentSnippet()}
<div class="flex gap-4 flex-wrap">
{#each data.users as user}
<ResourceCard title={user.name} subtitle="{user.groups.length} groups, {user.devices.length} devices">
{#snippet actionsSnippet()}
<Button Icon={IconEdit} a href="/dashboard/users/{user.id}" extra="!p-2"/>
{/snippet}
</ResourceCard>
{/each}
</div>
{/snippet}
</ResourceListPage>

View File

@ -1,13 +1,19 @@
import { db } from "$lib/server/db/db";
import type { Permission } from "$lib/server/db/types/permission";
import { prisma } from "$lib/server/db/db";
import { fail, redirect, type Actions } from "@sveltejs/kit";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
export const load = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
const user = db.data.users.find(user => user.id === params.slug);
const user = await prisma.user.findUnique({
where: {
id: parseInt(params.slug!) || -1,
},
include: {
groups: true,
devices: true
}
});
if (!user && params.slug !== "new") {
redirect(302, "/dashboard/users");
@ -15,8 +21,8 @@ export const load = async ({ locals: { guard }, params }) => {
return {
user,
groups: db.data.groups,
devices: db.data.devices,
groups: await prisma.group.findMany(),
devices: await prisma.device.findMany(),
}
};
@ -30,23 +36,8 @@ export const actions: Actions = {
const name = form.get("name")?.toString();
const admin = form.get("admin")?.toString() === "on";
const password = form.get("password")?.toString() ?? "";
const groups = form.getAll("groups")
.map(groupId => groupId.toString())
.filter(groupId => db.data.groups.find(group => group.id === groupId));
let permissions: { [key: string]: Permission } = {};
for (let deviceId of form.getAll("canSee")) {
if (db.data.devices.find(device => device.id === deviceId)) {
permissions[deviceId.toString()] = { wake: false };
}
}
for (let deviceId of form.getAll("canWake")) {
if (db.data.devices.find(device => device.id === deviceId)) {
permissions[deviceId.toString()] = { wake: true };
}
}
const groups = form.getAll("groups").map(g => ({ id: parseInt(g.toString()) }));
const devices = form.getAll("devices").map(d => ({ id: parseInt(d.toString()) }));
if (!name) {
// TODO better validation
@ -56,33 +47,40 @@ export const actions: Actions = {
}
if (params.slug === "new") {
if (password.length < 8) {
if (password.length < 4) {
return {
error: "PASSWORD_TOO_WEAK"
}
}
await db.update(({ users }) => {
users.push({
id: nanoid(),
await prisma.user.create({
data: {
name,
password: bcrypt.hashSync(password, 10),
admin,
groups,
permissions
});
groups: {
connect: groups
},
devices: {
connect: devices
}
}
});
} else {
await db.update(({ users }) => {
const user = users.find(user => user.id === params.slug);
if (user) {
user.name = name;
user.admin = admin;
if (password !== "") {
user.password = bcrypt.hashSync(password, 10);
}
user.groups = groups;
user.permissions = permissions;
await prisma.user.update({
where: {
id: parseInt(params.slug!) || -1,
},
data: {
name,
admin,
groups: {
set: groups
},
devices: {
set: devices
},
password: password.length > 0 ? bcrypt.hashSync(password, 10) : undefined
}
});
}

View File

@ -1,50 +1,60 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
import InputSelect from "$lib/components/forms/InputSelect.svelte";
import InputText from "$lib/components/forms/InputText.svelte";
import ResourceEditPage from "$lib/components/resources/ResourceEditPage.svelte";
import IconDeviceFloppy from "~icons/tabler/device-floppy";
import IconTrash from "~icons/tabler/trash";
import InputToggle from "$lib/components/forms/InputToggle.svelte";
import { enhance } from "$app/forms";
let { data, form } = $props();
</script>
<form method="POST" action="?/update">
<label>
Name
<input name="name" type="text" defaultValue={data.user ? data.user.name : "New User"}>
</label>
<label>
Password (leave empty for no change)
<input name="password" type="password">
</label>
<label>
Is admin
<input name="admin" type="checkbox" checked={data.user ? data.user.admin : false}>
</label>
<label>
Groups
<select multiple name="groups">
{#each data.groups as group}
<option value={group.id} selected={data.user?.groups.includes(group.id) ? true : false}>{group.name}</option>
{/each}
</select>
</label>
<label>
Can see
<select multiple name="canSee">
{#each data.devices as device}
<option value={device.id} selected={data.user?.permissions[device.id] ? true : false}>{device.name}</option>
{/each}
</select>
</label>
<label>
Can wake
<select multiple name="canWake">
{#each data.devices as device}
<option value={device.id} selected={data.user?.permissions[device.id]?.wake ? true : false}>{device.name}</option>
{/each}
</select>
</label>
<button>{data.user ? "Update" : "Create"}</button>
{#if data.user}
<button formaction="?/delete">Delete</button>
{/if}
</form>
<ResourceEditPage heading={data.user ? "Editing User - " + data.user.name : "New User"} error={form?.error}>
<form method="POST" action="?/update" use:enhance>
<InputText
label="User Name"
sublabel="How the user will appear in the dashboard"
name="name"
defaultValue={data.user ? data.user.name : "New User"}/>
{#if form?.error}
<p>Could not update user: {form.error}</p>
{/if}
<InputText
label="Password"
sublabel="Leave empty for no change"
name="password"
type="password"
defaultValue=""/>
<InputToggle
label="Admin"
name="admin"
sublabel="Can manage and use all devices, groups and users"
checked={data.user?.admin ?? false}/>
<InputSelect
label="Groups"
name="groups"
sublabel="Groups that the user will be a part of and inherit devices access from"
data={data.groups.map(g => ({
value: g.id.toString(),
name: g.name,
selected: data.user?.groups.find(ug => ug.id === g.id) ? true : false }))}/>
<InputSelect
label="Devices"
name="devices"
sublabel="Devices that the user can wake"
data={data.devices.map(d => ({
value: d.id.toString(),
name: d.name,
selected: data.user?.devices.find(ud => ud.id === d.id) ? true : false }))}/>
<div class="flex gap-5 items-center">
<Button Icon={IconDeviceFloppy}>{data.user ? "Update" : "Create"}</Button>
{#if data.user}
<Button Icon={IconTrash} color="red" inverted formaction="?/delete">Delete</Button>
{/if}
</div>
</form>
</ResourceEditPage>

View File

@ -1,7 +1,7 @@
import { createSession, getUserFromSession } from '$lib/server/sessions';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db/db';
import { prisma } from '$lib/server/db/db';
import bcrypt from 'bcryptjs';
export const actions = {
@ -20,7 +20,11 @@ export const actions = {
}
}
const user = db.data.users.find(user => user.name === username);
const user = await prisma.user.findUnique({
where: {
name: username
}
});
if (!user || !bcrypt.compareSync(password, user.password)) {
return {

View File

@ -1,7 +1,8 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import Icons from 'unplugin-icons/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [tailwindcss(), sveltekit(), Icons({ compiler: 'svelte' })],
});