From 2ac2650f99eeac74a8af45ac685d06fb10df203e Mon Sep 17 00:00:00 2001 From: axel Date: Wed, 16 Apr 2025 11:36:53 +0200 Subject: [PATCH] feat: v2 dashboard made from headless components from BitsUI to make the app more accessible --- package-lock.json | 150 ++++++++++++++++--- package.json | 1 + src/lib/server/db/types/user.ts | 8 + src/lib/v2/forms/InputCheckbox.svelte | 34 +++++ src/lib/v2/forms/InputCombobox.svelte | 139 +++++++++++++++++ src/lib/v2/forms/InputText.svelte | 29 ++++ src/lib/v2/globalStores.ts | 3 + src/lib/v2/snippets/EditPage.svelte | 52 +++++++ src/lib/v2/snippets/ListPage.svelte | 32 ++++ src/lib/v2/transitions/slideFade.ts | 45 ++++++ src/lib/v2/ui/Collapsible.svelte | 38 +++++ src/lib/v2/ui/NavBar.svelte | 13 ++ src/lib/v2/ui/NavBarLink.svelte | 24 +++ src/lib/v2/ui/ResCard.svelte | 37 +++++ src/routes/+page.server.ts | 4 +- src/routes/dash/+layout.server.ts | 10 ++ src/routes/dash/+layout.svelte | 98 ++++++++++++ src/routes/dash/+page.server.ts | 6 + src/routes/dash/devices/+page.server.ts | 9 ++ src/routes/dash/devices/+page.svelte | 22 +++ src/routes/dash/devices/[id]/+page.server.ts | 119 +++++++++++++++ src/routes/dash/devices/[id]/+page.svelte | 73 +++++++++ src/routes/dash/groups/+page.server.ts | 16 ++ src/routes/dash/groups/+page.svelte | 21 +++ src/routes/dash/groups/[id]/+page.server.ts | 83 ++++++++++ src/routes/dash/groups/[id]/+page.svelte | 41 +++++ src/routes/dash/users/+page.server.ts | 11 ++ src/routes/dash/users/+page.svelte | 21 +++ src/routes/dash/users/[id]/+page.server.ts | 111 ++++++++++++++ src/routes/dash/users/[id]/+page.svelte | 71 +++++++++ src/routes/login/+page.server.ts | 2 +- 31 files changed, 1301 insertions(+), 22 deletions(-) create mode 100644 src/lib/v2/forms/InputCheckbox.svelte create mode 100644 src/lib/v2/forms/InputCombobox.svelte create mode 100644 src/lib/v2/forms/InputText.svelte create mode 100644 src/lib/v2/globalStores.ts create mode 100644 src/lib/v2/snippets/EditPage.svelte create mode 100644 src/lib/v2/snippets/ListPage.svelte create mode 100644 src/lib/v2/transitions/slideFade.ts create mode 100644 src/lib/v2/ui/Collapsible.svelte create mode 100644 src/lib/v2/ui/NavBar.svelte create mode 100644 src/lib/v2/ui/NavBarLink.svelte create mode 100644 src/lib/v2/ui/ResCard.svelte create mode 100644 src/routes/dash/+layout.server.ts create mode 100644 src/routes/dash/+layout.svelte create mode 100644 src/routes/dash/+page.server.ts create mode 100644 src/routes/dash/devices/+page.server.ts create mode 100644 src/routes/dash/devices/+page.svelte create mode 100644 src/routes/dash/devices/[id]/+page.server.ts create mode 100644 src/routes/dash/devices/[id]/+page.svelte create mode 100644 src/routes/dash/groups/+page.server.ts create mode 100644 src/routes/dash/groups/+page.svelte create mode 100644 src/routes/dash/groups/[id]/+page.server.ts create mode 100644 src/routes/dash/groups/[id]/+page.svelte create mode 100644 src/routes/dash/users/+page.server.ts create mode 100644 src/routes/dash/users/+page.svelte create mode 100644 src/routes/dash/users/[id]/+page.server.ts create mode 100644 src/routes/dash/users/[id]/+page.svelte diff --git a/package-lock.json b/package-lock.json index bcf94a4..974ed5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@types/validator": "^13.12.3", "@types/wake_on_lan": "^0.0.33", "bcryptjs": "^3.0.2", + "bits-ui": "^1.3.19", "drizzle-orm": "^0.41.0", "lowdb": "^7.0.1", "nanoid": "^5.1.5", @@ -39,7 +40,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -498,6 +498,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@iconify-json/tabler": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/@iconify-json/tabler/-/tabler-1.2.17.tgz", @@ -532,11 +557,19 @@ "mlly": "^1.7.4" } }, + "node_modules/@internationalized/date": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.0.tgz", + "integrity": "sha512-J51AJ0fEL68hE4CwGPa6E0PO6JDaVLd8aln48xFCSy7CZkZc96dGEGmLs2OEEbBxcsVZtfrqkXJwI2/MSG8yKw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -551,7 +584,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -561,7 +593,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -571,14 +602,12 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1144,7 +1173,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1234,6 +1262,15 @@ "vite": "^6.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", @@ -1483,7 +1520,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -1525,7 +1561,6 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1538,7 +1573,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1548,7 +1582,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1563,6 +1596,31 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bits-ui": { + "version": "1.3.19", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-1.3.19.tgz", + "integrity": "sha512-2blb6dkgedHUsDXqCjvmtUi4Advgd9MhaJDT8r7bEWDzHI8HGsOoYsLeh8CxpEWWEYPrlGN+7k+kpxRhIDdFrQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.4", + "@floating-ui/dom": "^1.6.7", + "@internationalized/date": "^3.5.6", + "esm-env": "^1.1.2", + "runed": "^0.23.2", + "svelte-toolbelt": "^0.7.1", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "svelte": "^5.11.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1583,7 +1641,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1870,14 +1927,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz", "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2024,11 +2079,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -2383,7 +2443,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/lowdb": { @@ -2405,7 +2464,6 @@ "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -2768,6 +2826,21 @@ "fsevents": "~2.3.2" } }, + "node_modules/runed": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", + "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2853,11 +2926,19 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/svelte": { "version": "5.25.7", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.25.7.tgz", "integrity": "sha512-0fzXbXaKfSvFUs6Wxev2h4CoEhexZotbTF9EJ4+Cg7MHW64ZnZ9+xUedZyEpgj0Tt9HrYGv9aASHkqjn9b/cPw==", - "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -2903,6 +2984,32 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-toolbelt": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", + "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.23.2", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", @@ -2937,6 +3044,12 @@ "node": ">=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==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", @@ -3217,7 +3330,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", - "dev": true, "license": "MIT" }, "node_modules/zod": { diff --git a/package.json b/package.json index ae1c6f4..2495be4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/validator": "^13.12.3", "@types/wake_on_lan": "^0.0.33", "bcryptjs": "^3.0.2", + "bits-ui": "^1.3.19", "drizzle-orm": "^0.41.0", "lowdb": "^7.0.1", "nanoid": "^5.1.5", diff --git a/src/lib/server/db/types/user.ts b/src/lib/server/db/types/user.ts index c101276..472ec8c 100644 --- a/src/lib/server/db/types/user.ts +++ b/src/lib/server/db/types/user.ts @@ -6,3 +6,11 @@ export type User = { groups: string[]; devices: string[]; }; + +export type PublicUser = Omit; + +export function toPublicUser(user: User): PublicUser { + const clonedUser = structuredClone(user) as Partial; + delete clonedUser.password; + return clonedUser as PublicUser; +} diff --git a/src/lib/v2/forms/InputCheckbox.svelte b/src/lib/v2/forms/InputCheckbox.svelte new file mode 100644 index 0000000..9df76fa --- /dev/null +++ b/src/lib/v2/forms/InputCheckbox.svelte @@ -0,0 +1,34 @@ + + +
+
+ {label} + + {#snippet children({ checked })} +
+ +
+ {/snippet} +
+
+ {#if description} +

{description}

+ {/if} +
diff --git a/src/lib/v2/forms/InputCombobox.svelte b/src/lib/v2/forms/InputCombobox.svelte new file mode 100644 index 0000000..6ee65be --- /dev/null +++ b/src/lib/v2/forms/InputCombobox.svelte @@ -0,0 +1,139 @@ + + +
+ inputRef?.focus()} + class="block uppercase mb-2 cursor-pointer" + > + {label} + + + { + if (!val) searchTerm = ''; + }} + > +
+
+ (searchTerm = e.currentTarget.value.toLowerCase())} + class="bg-neutral-950 px-4 py-2 rounded-l-full + focus:ring-indigo-500 focus:border-indigo-500 {extraClasses} z-1" + /> + +
+ +
+ Select + +
+
+
+ + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ +
+ +
+ + {#if searchFiltered.length > 0} + {#each searchFiltered as el} + + {#snippet children({ selected })} + {el.name} + + {/snippet} + + {/each} + {:else} +

+ No results found. Try another term. +

+ {/if} +
+ + +
+
+
+
+ {/if} + {/snippet} +
+
+
+ + {#if description} +

{description}

+ {/if} +
diff --git a/src/lib/v2/forms/InputText.svelte b/src/lib/v2/forms/InputText.svelte new file mode 100644 index 0000000..751f51d --- /dev/null +++ b/src/lib/v2/forms/InputText.svelte @@ -0,0 +1,29 @@ + + +
+ {label} + + {#if description} +

{description}

+ {/if} +
diff --git a/src/lib/v2/globalStores.ts b/src/lib/v2/globalStores.ts new file mode 100644 index 0000000..8bf91e9 --- /dev/null +++ b/src/lib/v2/globalStores.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const pageTitle = writable(''); diff --git a/src/lib/v2/snippets/EditPage.svelte b/src/lib/v2/snippets/EditPage.svelte new file mode 100644 index 0000000..af327fe --- /dev/null +++ b/src/lib/v2/snippets/EditPage.svelte @@ -0,0 +1,52 @@ + + +
+
+ {@render children()} + +
+
+ + +

Save

+
+ + history.back()} + class="flex items-center gap-2 text-white cursor-pointer + bg-neutral-800 hover:bg-neutral-700 hover:scale-95 transition-all duration-300 ease-in-out + rounded-full py-2 px-4 border border-neutral-600" + > + +

Cancel

+
+
+ + {#if !createOnly} + + +

Delete

+
+ {/if} +
+
+
diff --git a/src/lib/v2/snippets/ListPage.svelte b/src/lib/v2/snippets/ListPage.svelte new file mode 100644 index 0000000..657572e --- /dev/null +++ b/src/lib/v2/snippets/ListPage.svelte @@ -0,0 +1,32 @@ + + +{#if createHref} + + + + +{/if} + +
+ {#if children} + {@render children?.()} + {:else} +
+

Nothing here.

+ {#if createHref} +

Get started by clicking "{msgAdd}".

+ {/if} +
+ {/if} +
diff --git a/src/lib/v2/transitions/slideFade.ts b/src/lib/v2/transitions/slideFade.ts new file mode 100644 index 0000000..a9e971c --- /dev/null +++ b/src/lib/v2/transitions/slideFade.ts @@ -0,0 +1,45 @@ +import { cubicOut } from 'svelte/easing'; +import type { TransitionConfig } from 'svelte/transition'; + +// mostly copied from svelte @ +// https://github.com/sveltejs/svelte/blob/6a7e53feaa53425624a47d7ebed98ff8d6fb1d8b/packages/svelte/src/transition/index.js#L108 + +// opacity was already affected by time but at 20 times the rate of the other properties?? +// why? made no sense, opacity changed so fast you couldn't see it... oh well + +export function slideFade( + node: Element, + { delay = 0, duration = 300, easing = cubicOut, axis = 'y' } = {}, +): TransitionConfig { + const style = getComputedStyle(node); + + const opacity = +style.opacity; + + const primaryProp = axis === 'y' ? 'height' : 'width'; + const primaryVal = parseFloat(style.getPropertyValue(primaryProp)); + + const secondaryProps = axis === 'y' ? ['top', 'bottom'] : ['left', 'right']; + const padStart = parseFloat(style.getPropertyValue(`padding-${secondaryProps[0]}`)); + const padEnd = parseFloat(style.getPropertyValue(`padding-${secondaryProps[1]}`)); + const marginStart = parseFloat(style.getPropertyValue(`margin-${secondaryProps[0]}`)); + const marginEnd = parseFloat(style.getPropertyValue(`margin-${secondaryProps[1]}`)); + const borderWidthStart = parseFloat(style.getPropertyValue(`border-${secondaryProps[0]}-width`)); + const borderWidthEnd = parseFloat(style.getPropertyValue(`border-${secondaryProps[1]}-width`)); + + return { + delay, + duration, + easing, + css: (t) => + 'overflow: hidden;' + + `opacity: ${t * opacity};` + + `${primaryProp}: ${t * primaryVal}px;` + + `padding-${secondaryProps[0]}: ${t * padStart}px;` + + `padding-${secondaryProps[1]}: ${t * padEnd}px;` + + `margin-${secondaryProps[0]}: ${t * marginStart}px;` + + `margin-${secondaryProps[1]}: ${t * marginEnd}px;` + + `border-${secondaryProps[0]}-width: ${t * borderWidthStart}px;` + + `border-${secondaryProps[1]}-width: ${t * borderWidthEnd}px;` + + `min-${primaryProp}: 0`, + }; +} diff --git a/src/lib/v2/ui/Collapsible.svelte b/src/lib/v2/ui/Collapsible.svelte new file mode 100644 index 0000000..e912f5a --- /dev/null +++ b/src/lib/v2/ui/Collapsible.svelte @@ -0,0 +1,38 @@ + + +
+ (expanded = !expanded)} + class="flex items-center gap-2 cursor-pointer text-neutral-500 uppercase text-sm" + > + + {label} + + + + + + {#if expanded} +
+ {@render children()} +
+ {:else if renderHidden} + + + {/if} +
diff --git a/src/lib/v2/ui/NavBar.svelte b/src/lib/v2/ui/NavBar.svelte new file mode 100644 index 0000000..bd48047 --- /dev/null +++ b/src/lib/v2/ui/NavBar.svelte @@ -0,0 +1,13 @@ + + + diff --git a/src/lib/v2/ui/NavBarLink.svelte b/src/lib/v2/ui/NavBarLink.svelte new file mode 100644 index 0000000..df5f09f --- /dev/null +++ b/src/lib/v2/ui/NavBarLink.svelte @@ -0,0 +1,24 @@ + + +
  • + + {#if Icon} + + {/if} + + {@render children?.()} + +
  • diff --git a/src/lib/v2/ui/ResCard.svelte b/src/lib/v2/ui/ResCard.svelte new file mode 100644 index 0000000..857ccd2 --- /dev/null +++ b/src/lib/v2/ui/ResCard.svelte @@ -0,0 +1,37 @@ + + +
    +
    +

    {title}

    +

    {subtitle}

    +
    + +
    + {#if editHref} + + + + {/if} + + {#if wakePost} + + + + {/if} +
    +
    diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 7ef94f5..e16994d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - redirect(302, '/dashboard'); -}; \ No newline at end of file + redirect(302, '/dash'); +}; diff --git a/src/routes/dash/+layout.server.ts b/src/routes/dash/+layout.server.ts new file mode 100644 index 0000000..efc2f17 --- /dev/null +++ b/src/routes/dash/+layout.server.ts @@ -0,0 +1,10 @@ +import { toPublicUser } from '$lib/server/db/types/user'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals: { guard } }) => { + const user = guard.requiresAuth().orRedirects().getUser(); + + return { + user: toPublicUser(user), + }; +}; diff --git a/src/routes/dash/+layout.svelte b/src/routes/dash/+layout.svelte new file mode 100644 index 0000000..24e31f5 --- /dev/null +++ b/src/routes/dash/+layout.svelte @@ -0,0 +1,98 @@ + + + + Dashboard - {$pageTitle} + + +
    + + Devices + Users + Groups + +
    +
    + +

    {$pageTitle}

    +
    + + + + {data.user.name} + + + + + {#snippet child({ wrapperProps, props, open })} + {#if open} + + {/if} + {/snippet} + + +
    +
    + +
    + {@render children()} +
    diff --git a/src/routes/dash/+page.server.ts b/src/routes/dash/+page.server.ts new file mode 100644 index 0000000..6be87dd --- /dev/null +++ b/src/routes/dash/+page.server.ts @@ -0,0 +1,6 @@ +import { redirect, type ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ locals: { guard } }) => { + guard.requiresAuth().orRedirects(); + redirect(302, '/dash/devices'); +}; diff --git a/src/routes/dash/devices/+page.server.ts b/src/routes/dash/devices/+page.server.ts new file mode 100644 index 0000000..7efc0a0 --- /dev/null +++ b/src/routes/dash/devices/+page.server.ts @@ -0,0 +1,9 @@ +import { getUsersDevices } from '$lib/server/db'; +import type { ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ locals: { guard } }) => { + const user = guard.requiresAuth().orRedirects().getUser(); + return { + devices: getUsersDevices(user.id), + }; +}; diff --git a/src/routes/dash/devices/+page.svelte b/src/routes/dash/devices/+page.svelte new file mode 100644 index 0000000..f79d5ce --- /dev/null +++ b/src/routes/dash/devices/+page.svelte @@ -0,0 +1,22 @@ + + + +
    + {#each data.devices as device} + + {/each} +
    +
    diff --git a/src/routes/dash/devices/[id]/+page.server.ts b/src/routes/dash/devices/[id]/+page.server.ts new file mode 100644 index 0000000..cf561e4 --- /dev/null +++ b/src/routes/dash/devices/[id]/+page.server.ts @@ -0,0 +1,119 @@ +import { db, getUsersDevices } from '$lib/server/db/index.js'; +import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; +import { nanoid } from 'nanoid'; +import validator from 'validator'; +import { wake } from 'wake_on_lan'; +import { z } from 'zod'; + +export const load: ServerLoad = async ({ locals: { guard }, params }) => { + guard.requiresAdmin().orRedirects(); + + const device = db.data.devices.find((d) => d.id === params.id); + + if (!device && params.id !== 'new') { + redirect(302, '/dash/devices'); + } + + return { + device, + }; +}; + +export const actions = { + update: async ({ request, params, locals: { guard } }) => { + if (guard.requiresAdmin().isFailed()) return fail(403); + + const form = await request.formData(); + + const schema = z.object({ + name: z + .string({ message: 'Name is required.' }) + .min(3, { message: 'Name must be at least 3 characters.' }) + .max(24, { message: 'Name must be at most 24 characters.' }), + mac: z + .string({ message: 'MAC address is required.' }) + .refine((v) => validator.isMACAddress(v), { message: 'Invalid MAC address.' }), + broadcast: z + .string() + .refine((v) => validator.isIP(v), { message: 'Invalid broadcast IP address.' }), + port: z.coerce + .number({ message: 'Port is invalid.' }) + .min(1, { message: 'Port must be at least 1.' }) + .max(65535, { message: 'Port must be at most 65535.' }), + packets: z.coerce + .number({ message: 'Packets quantity is invalid.' }) + .min(1, { message: 'Packets quantity must be at least 1.' }) + .max(50, { message: 'Packets quantity must be at most 50.' }), + }); + + const parsed = schema.safeParse({ + name: form.get('name'), + mac: form.get('mac'), + broadcast: form.get('broadcast'), + port: form.get('port'), + packets: form.get('packets'), + }); + + if (!parsed.success) { + return fail(400, { error: parsed.error.errors[0].message }); + } + + if (params.id === 'new') { + await db.update(({ devices }) => { + devices.push({ id: nanoid(), ...parsed.data }); + }); + + return { success: true }; + } + + let found = false; + + await db.update(({ devices }) => { + let dev = devices.find((d) => d.id === params.id); + if (!dev) return; + + Object.assign(dev, parsed.data); + found = true; + }); + + return found ? { success: true } : fail(404, { error: 'Device not found.' }); + }, + delete: async ({ locals: { guard }, params }) => { + if (guard.requiresAdmin().isFailed()) { + return fail(403); + } + + db.data.devices = db.data.devices.filter((d) => d.id !== params.id); + db.data.users.forEach((u) => { + u.devices = u.devices.filter((d) => d !== params.id); + }); + db.data.groups.forEach((g) => { + g.devices = g.devices.filter((d) => d !== params.id); + }); + db.write(); + + return { success: true }; + }, + wake: async ({ params, locals: { guard } }) => { + guard = guard.requiresAuth(); + + if (guard.isFailed()) { + console.log('Failed guard'); + return fail(403); + } + + const device = getUsersDevices(guard.getUser().id).find((d) => d.id === params.id); + + 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, + }); + }, +} satisfies Actions; diff --git a/src/routes/dash/devices/[id]/+page.svelte b/src/routes/dash/devices/[id]/+page.svelte new file mode 100644 index 0000000..3f4433f --- /dev/null +++ b/src/routes/dash/devices/[id]/+page.svelte @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + diff --git a/src/routes/dash/groups/+page.server.ts b/src/routes/dash/groups/+page.server.ts new file mode 100644 index 0000000..7d6cd03 --- /dev/null +++ b/src/routes/dash/groups/+page.server.ts @@ -0,0 +1,16 @@ +import { db } from '$lib/server/db'; +import { type ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ locals: { guard } }) => { + guard.requiresAdmin().orRedirects(); + return { + groups: db.data.groups, + userCounts: db.data.groups.reduce( + (acc, group) => { + acc[group.id] = db.data.users.filter((u) => u.groups.includes(group.id)).length; + return acc; + }, + {} as Record, + ), + }; +}; diff --git a/src/routes/dash/groups/+page.svelte b/src/routes/dash/groups/+page.svelte new file mode 100644 index 0000000..4a44b4c --- /dev/null +++ b/src/routes/dash/groups/+page.svelte @@ -0,0 +1,21 @@ + + + +
    + {#each data.groups as group} + + {/each} +
    +
    diff --git a/src/routes/dash/groups/[id]/+page.server.ts b/src/routes/dash/groups/[id]/+page.server.ts new file mode 100644 index 0000000..13f2964 --- /dev/null +++ b/src/routes/dash/groups/[id]/+page.server.ts @@ -0,0 +1,83 @@ +import { db } from '$lib/server/db'; +import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; + +export const load: ServerLoad = async ({ locals: { guard }, params }) => { + guard.requiresAdmin().orRedirects(); + + const group = db.data.groups.find((g) => g.id === params.id); + + if (!group && params.id !== 'new') { + redirect(302, '/dash/groups'); + } + + return { + devices: db.data.devices, + group, + }; +}; + +export const actions = { + update: async ({ request, locals: { guard }, params }) => { + if (guard.requiresAdmin().isFailed()) return fail(403); + + const form = await request.formData(); + + const schema = z.object({ + name: z + .string({ message: 'Name is required.' }) + .min(3, { message: 'Name must be at least 3 characters.' }) + .max(24, { message: 'Name must be at most 24 characters.' }), + + devices: z.array( + z.string().refine((v) => db.data.devices.find((d) => d.id === v), { + message: 'Invalid device ID.', + }), + ), + }); + + const parsed = schema.safeParse({ + name: form.get('name'), + devices: form.getAll('devices'), + }); + + if (!parsed.success) { + return fail(400, { error: parsed.error.errors[0].message }); + } + + if (params.id === 'new') { + await db.update(({ groups }) => { + groups.push({ id: nanoid(), ...parsed.data }); + }); + + return { success: true }; + } + + let found = false; + + await db.update(({ groups }) => { + let group = groups.find((g) => g.id === params.id); + if (!group) return; + + Object.assign(group, parsed.data); + found = true; + }); + + return found ? { success: true } : fail(400, { error: 'Group not found.' }); + }, + + delete: async ({ locals: { guard }, params }) => { + if (guard.requiresAdmin().isFailed()) { + return fail(403); + } + + db.data.groups = db.data.groups.filter((g) => g.id !== params.id); + db.data.users.forEach((u) => { + u.groups = u.groups.filter((g) => g !== params.id); + }); + db.write(); + + redirect(302, '/dashboard/groups'); + }, +} satisfies Actions; diff --git a/src/routes/dash/groups/[id]/+page.svelte b/src/routes/dash/groups/[id]/+page.svelte new file mode 100644 index 0000000..993304b --- /dev/null +++ b/src/routes/dash/groups/[id]/+page.svelte @@ -0,0 +1,41 @@ + + + + + + ({ + value: d.id, + name: d.name, + selected: data.group?.devices.find((devId) => devId === d.id) ? true : false, + }))} + /> + diff --git a/src/routes/dash/users/+page.server.ts b/src/routes/dash/users/+page.server.ts new file mode 100644 index 0000000..6801ad3 --- /dev/null +++ b/src/routes/dash/users/+page.server.ts @@ -0,0 +1,11 @@ +import { db } from '$lib/server/db'; +import { toPublicUser, type User } from '$lib/server/db/types/user'; +import type { ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ locals: { guard } }) => { + guard.requiresAdmin().orRedirects(); + + return { + users: db.data.users.map((u) => toPublicUser(u)), + }; +}; diff --git a/src/routes/dash/users/+page.svelte b/src/routes/dash/users/+page.svelte new file mode 100644 index 0000000..99e9ece --- /dev/null +++ b/src/routes/dash/users/+page.svelte @@ -0,0 +1,21 @@ + + + +
    + {#each data.users as user} + + {/each} +
    +
    diff --git a/src/routes/dash/users/[id]/+page.server.ts b/src/routes/dash/users/[id]/+page.server.ts new file mode 100644 index 0000000..eb536c5 --- /dev/null +++ b/src/routes/dash/users/[id]/+page.server.ts @@ -0,0 +1,111 @@ +import { db } from '$lib/server/db'; +import { toPublicUser, type User } from '$lib/server/db/types/user.js'; +import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; +import bcrypt from 'bcryptjs'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; + +export const load: ServerLoad = async ({ locals: { guard }, params }) => { + guard.requiresAdmin().orRedirects(); + + let user = db.data.users.find((u) => u.id === params.id); + + if (!user && params.id !== 'new') { + redirect(302, '/dashboard/users'); + } + + return { + user: toPublicUser(user!), + groups: db.data.groups, + devices: db.data.devices, + }; +}; + +export const actions = { + update: async ({ request, locals: { guard }, params }) => { + if (guard.requiresAdmin().isFailed()) return fail(403); + + const form = await request.formData(); + + const schema = z.object({ + name: z + .string({ message: 'Name is required.' }) + .min(3, { message: 'Name must be at least 3 characters.' }) + .max(24, { message: 'Name must be at most 24 characters.' }), + admin: z.boolean(), + password: z + .string() + .optional() + .refine((v) => (params.id === 'new' ? v && v.length > 0 : true), { + message: 'Password is required at user creation.', + }) + .refine((v) => !v || v.length >= 8, { + message: 'Password must be at least 8 characters.', + }), + groups: z.array( + z.string().refine((v) => db.data.groups.find((g) => g.id === v), { + message: 'Invalid group ID.', + }), + ), + devices: z.array( + z.string().refine((v) => db.data.devices.find((d) => d.id === v), { + message: 'Invalid device ID.', + }), + ), + }); + + const parsed = schema.safeParse({ + name: form.get('name'), + admin: form.get('admin') === 'on', + password: form.get('password'), + groups: form.getAll('groups'), + devices: form.getAll('devices'), + }); + + if (!parsed.success) { + return fail(400, { error: parsed.error.errors[0].message }); + } + + if (params.id === 'new') { + await db.update(({ users }) => { + users.push({ + id: nanoid(), + name: parsed.data.name, + admin: parsed.data.admin, + groups: parsed.data.groups, + devices: parsed.data.devices, + password: bcrypt.hashSync(parsed.data.password!, 10), + }); + }); + + return { success: true }; + } + + let found = false; + await db.update(({ users }) => { + let user = users.find((u) => u.id === params.id); + if (!user) return; + + user.name = parsed.data.name; + user.admin = parsed.data.admin; + user.groups = parsed.data.groups; + user.devices = parsed.data.devices; + + if (parsed.data.password && parsed.data.password.length > 0) { + user.password = bcrypt.hashSync(parsed.data.password, 10); + } + + found = true; + }); + + return found ? { success: true } : fail(404, { error: 'User not found.' }); + }, + delete: async ({ locals: { guard }, params }) => { + if (guard.requiresAdmin().isFailed()) { + return fail(403); + } + + db.data.users = db.data.users.filter((u) => u.id !== params.id); + db.write(); + }, +} satisfies Actions; diff --git a/src/routes/dash/users/[id]/+page.svelte b/src/routes/dash/users/[id]/+page.svelte new file mode 100644 index 0000000..cbefb9d --- /dev/null +++ b/src/routes/dash/users/[id]/+page.svelte @@ -0,0 +1,71 @@ + + + + + + + + + + ({ + value: d.id, + name: d.name, + selected: data.user?.devices.find((devId) => devId === d.id) ? true : false, + }))} + /> + + ({ + value: g.id, + name: g.name, + selected: data.user?.groups.find((groupId) => groupId === g.id) ? true : false, + }))} + /> + diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 44e5f90..c5bda56 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -44,6 +44,6 @@ export const actions = { }, ); - redirect(302, '/dashboard'); + redirect(302, '/dash'); }, } satisfies Actions;