feat: v2 dashboard
made from headless components from BitsUI to make the app more accessible
This commit is contained in:
parent
d547af7272
commit
2ac2650f99
150
package-lock.json
generated
150
package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -6,3 +6,11 @@ export type User = {
|
||||
groups: string[];
|
||||
devices: string[];
|
||||
};
|
||||
|
||||
export type PublicUser = Omit<User, 'password'>;
|
||||
|
||||
export function toPublicUser(user: User): PublicUser {
|
||||
const clonedUser = structuredClone(user) as Partial<User>;
|
||||
delete clonedUser.password;
|
||||
return clonedUser as PublicUser;
|
||||
}
|
||||
|
||||
34
src/lib/v2/forms/InputCheckbox.svelte
Normal file
34
src/lib/v2/forms/InputCheckbox.svelte
Normal file
@ -0,0 +1,34 @@
|
||||
<script>
|
||||
import { Checkbox, Label } from 'bits-ui';
|
||||
import IconCheck from '~icons/tabler/check';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
description = null,
|
||||
class: extraClasses = '',
|
||||
checked = $bindable(),
|
||||
...others
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="text-sm text-neutral-100">
|
||||
<div class="flex items-center gap-4 {description ? 'mb-2' : 'mb-6'}">
|
||||
<Label.Root for={name} class="block uppercase cursor-pointer">{label}</Label.Root>
|
||||
<Checkbox.Root id={name} {name} bind:checked {...others}>
|
||||
{#snippet children({ checked })}
|
||||
<div
|
||||
class="p-1 border border-neutral-600 rounded-lg bg-neutral-950 hover:bg-neutral-800 transition-all
|
||||
duration-300 ease-in-out cursor-pointer"
|
||||
>
|
||||
<IconCheck
|
||||
class="transition-all duration-300 ease-in-out {checked ? 'opacity-100' : 'opacity-0'} test"
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
</div>
|
||||
{#if description}
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
139
src/lib/v2/forms/InputCombobox.svelte
Normal file
139
src/lib/v2/forms/InputCombobox.svelte
Normal file
@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { Combobox, Label } from 'bits-ui';
|
||||
import IconCaretDown from '~icons/tabler/caret-down';
|
||||
import IconCaretUp from '~icons/tabler/caret-up';
|
||||
import IconCheck from '~icons/tabler/check';
|
||||
import IconSelector from '~icons/tabler/selector';
|
||||
import { slideFade } from '../transitions/slideFade';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
description = '',
|
||||
data,
|
||||
class: extraClasses = '',
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
data: {
|
||||
value: string;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}[];
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let searchFiltered = $derived(data.filter((e) => e.name.toLowerCase().startsWith(searchTerm)));
|
||||
|
||||
let value = $state(data.filter((d) => d.selected).map((d) => d.value));
|
||||
let inputRef: HTMLInputElement | null = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="text-sm text-neutral-100">
|
||||
<Label.Root
|
||||
for={name}
|
||||
onclick={() => inputRef?.focus()}
|
||||
class="block uppercase mb-2 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</Label.Root>
|
||||
|
||||
<Combobox.Root
|
||||
type="multiple"
|
||||
{name}
|
||||
bind:value
|
||||
onOpenChange={(val) => {
|
||||
if (!val) searchTerm = '';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex border border-neutral-600 shadow text-sm rounded-full {description
|
||||
? 'mb-2'
|
||||
: 'mb-6'}"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<Combobox.Input
|
||||
bind:ref={inputRef}
|
||||
placeholder="{value.length} selected | Type to search..."
|
||||
oninput={(e) => (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"
|
||||
/>
|
||||
<!-- <Button.Root type="button" class="{searchTerm == '' ? 'hidden' : ''} absolute text-neutral-500 right-3 top-1/2 -translate-y-1/2">
|
||||
<IconX/>
|
||||
</Button.Root> -->
|
||||
</div>
|
||||
<Combobox.Trigger
|
||||
class="border-l border-neutral-600 pl-4 pr-3 py-2 rounded-r-full
|
||||
cursor-pointer bg-neutral-800 hover:bg-neutral-700 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
Select
|
||||
<IconSelector />
|
||||
</div>
|
||||
</Combobox.Trigger>
|
||||
</div>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content
|
||||
forceMount
|
||||
class="flex flex-col mt-2 min-w-50 sm:min-w-70 max-h-70 shadow-lg
|
||||
border border-neutral-600 rounded-2xl bg-neutral-950 p-2 text-sm text-neutral-100"
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:slideFade>
|
||||
<Combobox.ScrollUpButton class="flex justify-center relative">
|
||||
<div
|
||||
class="absolute top-full inset-x-0 h-5 z-50 pointer-events-none
|
||||
bg-gradient-to-b from-neutral-950 to-transparent"
|
||||
></div>
|
||||
<IconCaretUp />
|
||||
</Combobox.ScrollUpButton>
|
||||
<Combobox.Viewport>
|
||||
{#if searchFiltered.length > 0}
|
||||
{#each searchFiltered as el}
|
||||
<Combobox.Item
|
||||
label={searchTerm}
|
||||
value={el.value}
|
||||
class="flex items-center justify-between gap-4 px-4 py-2
|
||||
data-highlighted:bg-neutral-800 rounded-xl
|
||||
cursor-pointer transition-all duration-150 ease-in-out"
|
||||
>
|
||||
{#snippet children({ selected })}
|
||||
{el.name}
|
||||
<IconCheck
|
||||
class="transition-all duration-150 ease-in-out {selected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
/>
|
||||
{/snippet}
|
||||
</Combobox.Item>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="px-4 py-2 text-neutral-500 cursor-not-allowed">
|
||||
No results found. Try another term.
|
||||
</p>
|
||||
{/if}
|
||||
</Combobox.Viewport>
|
||||
<Combobox.ScrollDownButton class="flex justify-center relative">
|
||||
<IconCaretDown />
|
||||
<div
|
||||
class="absolute bottom-full inset-x-0 h-5 z-50 pointer-events-none
|
||||
bg-gradient-to-t from-neutral-950 to-transparent"
|
||||
></div>
|
||||
</Combobox.ScrollDownButton>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox.Root>
|
||||
|
||||
{#if description}
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
29
src/lib/v2/forms/InputText.svelte
Normal file
29
src/lib/v2/forms/InputText.svelte
Normal file
@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { Label } from 'bits-ui';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
description = null,
|
||||
class: extraClasses = '',
|
||||
value = $bindable(),
|
||||
...others
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="text-sm text-neutral-100">
|
||||
<Label.Root for={name} class="block uppercase mb-2 cursor-pointer">{label}</Label.Root>
|
||||
<input
|
||||
id={name}
|
||||
{name}
|
||||
bind:value
|
||||
type="text"
|
||||
{...others}
|
||||
class="border border-neutral-600 shadow bg-neutral-950
|
||||
px-4 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500 rounded-full
|
||||
{extraClasses} {description ? 'mb-2' : 'mb-6'}"
|
||||
/>
|
||||
{#if description}
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mb-6">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
3
src/lib/v2/globalStores.ts
Normal file
3
src/lib/v2/globalStores.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const pageTitle = writable('');
|
||||
52
src/lib/v2/snippets/EditPage.svelte
Normal file
52
src/lib/v2/snippets/EditPage.svelte
Normal file
@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from 'bits-ui';
|
||||
import IconDeviceFloppy from '~icons/tabler/device-floppy';
|
||||
import IconTrash from '~icons/tabler/trash';
|
||||
import IconX from '~icons/tabler/x';
|
||||
|
||||
let { children, createOnly } = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full sm:px-6 px-3 py-4 max-w-xl mx-auto mb-12">
|
||||
<form method="POST" action="?/update" use:enhance>
|
||||
{@render children()}
|
||||
|
||||
<div class="flex gap-2 justify-between sm:text-base text-sm">
|
||||
<div class="flex gap-2">
|
||||
<Button.Root
|
||||
type="submit"
|
||||
class="flex items-center gap-2 text-black cursor-pointer
|
||||
bg-neutral-100 hover:bg-neutral-400 hover:scale-95 transition-all duration-300 ease-in-out
|
||||
rounded-full py-2 px-4 border border-neutral-100"
|
||||
>
|
||||
<IconDeviceFloppy />
|
||||
<p>Save</p>
|
||||
</Button.Root>
|
||||
|
||||
<Button.Root
|
||||
type="button"
|
||||
onclick={() => 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"
|
||||
>
|
||||
<IconX />
|
||||
<p>Cancel</p>
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
{#if !createOnly}
|
||||
<Button.Root
|
||||
formaction="?/delete"
|
||||
class="flex items-center gap-2 text-white cursor-pointer
|
||||
bg-red-700 hover:bg-red-500 hover:scale-95 transition-all duration-300 ease-in-out
|
||||
rounded-full py-2 px-4 border border-red-500"
|
||||
>
|
||||
<IconTrash />
|
||||
<p>Delete</p>
|
||||
</Button.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
32
src/lib/v2/snippets/ListPage.svelte
Normal file
32
src/lib/v2/snippets/ListPage.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Button } from 'bits-ui';
|
||||
import IconPlus from '~icons/tabler/plus';
|
||||
|
||||
let { createHref = null, msgAdd, children } = $props();
|
||||
</script>
|
||||
|
||||
{#if createHref}
|
||||
<Button.Root
|
||||
href={createHref}
|
||||
class="fixed flex items-center sm:bottom-12 sm:right-12 bottom-6 right-6
|
||||
bg-neutral-100 rounded-full shadow-xl sm:px-6 sm:py-2 p-4
|
||||
transition-all duration-300 ease-in-out
|
||||
hover:bg-neutral-300 hover:scale-105"
|
||||
>
|
||||
<span class="hidden sm:block">{msgAdd}</span>
|
||||
<IconPlus class="sm:ml-2" />
|
||||
</Button.Root>
|
||||
{/if}
|
||||
|
||||
<div class="w-full sm:px-6 px-3 py-4 max-w-3xl mx-auto">
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<div class="p-4 text-center text-neutral-700">
|
||||
<p>Nothing here.</p>
|
||||
{#if createHref}
|
||||
<p>Get started by clicking "{msgAdd}".</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
45
src/lib/v2/transitions/slideFade.ts
Normal file
45
src/lib/v2/transitions/slideFade.ts
Normal file
@ -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`,
|
||||
};
|
||||
}
|
||||
38
src/lib/v2/ui/Collapsible.svelte
Normal file
38
src/lib/v2/ui/Collapsible.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { Button } from 'bits-ui';
|
||||
import IconChevronRight from '~icons/tabler/chevron-right';
|
||||
import { slideFade } from '../transitions/slideFade';
|
||||
|
||||
let {
|
||||
children,
|
||||
label,
|
||||
expanded = $bindable(false),
|
||||
renderHidden = false,
|
||||
class: extraClasses = '',
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class={extraClasses}>
|
||||
<Button.Root
|
||||
type="button"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
class="flex items-center gap-2 cursor-pointer text-neutral-500 uppercase text-sm"
|
||||
>
|
||||
<IconChevronRight class="{expanded ? 'rotate-90' : ''} transition-all duration-300" />
|
||||
{label}
|
||||
</Button.Root>
|
||||
|
||||
<!-- a bit hacky, all of that just to keep a smooth slide transition with no side effect -->
|
||||
<!-- i wish i could just use CSS transitions and display: none -->
|
||||
<!-- but noooo, CSS won't let you animate height: auto because fuck you that's why -->
|
||||
{#if expanded}
|
||||
<div transition:slideFade class="mt-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else if renderHidden}
|
||||
<!-- keep a hidden render, useful to send form data even if hidden -->
|
||||
<div class="hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
13
src/lib/v2/ui/NavBar.svelte
Normal file
13
src/lib/v2/ui/NavBar.svelte
Normal file
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
let { children, class: className = '' } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="flex items-center {className}">
|
||||
<ul
|
||||
role="menubar"
|
||||
class="flex items-center w-full sm:w-auto justify-between gap-1 p-1 bg-neutral-950
|
||||
border border-neutral-600 rounded-full"
|
||||
>
|
||||
{@render children()}
|
||||
</ul>
|
||||
</nav>
|
||||
24
src/lib/v2/ui/NavBarLink.svelte
Normal file
24
src/lib/v2/ui/NavBarLink.svelte
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { Button } from 'bits-ui';
|
||||
|
||||
let { Icon = null, children, active, ...others } = $props();
|
||||
</script>
|
||||
|
||||
<li role="none">
|
||||
<Button.Root
|
||||
role="menuitem"
|
||||
class="flex items-center cursor-pointer px-4 py-1 rounded-full transition-all duration-300 ease-in-out
|
||||
{active ? 'bg-neutral-100 text-neutral-900' : 'hover:bg-neutral-600'}"
|
||||
{...others}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="block overflow-hidden transition-all duration-300 ease-in-out
|
||||
{children ? 'w-0 text-neutral-900' : ''}
|
||||
{active ? 'w-5 mr-2' : ''}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</Button.Root>
|
||||
</li>
|
||||
37
src/lib/v2/ui/ResCard.svelte
Normal file
37
src/lib/v2/ui/ResCard.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { Button } from 'bits-ui';
|
||||
import IconEdit from '~icons/tabler/edit';
|
||||
import IconPlay from '~icons/tabler/player-play';
|
||||
|
||||
let { title, subtitle, editHref = null, wakePost = null } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-10 items-center rounded-full border border-neutral-600 py-3 pl-7 pr-3 bg-neutral-950 shadow"
|
||||
>
|
||||
<div class="flex flex-col text-sm">
|
||||
<p class="text-neutral-300">{title}</p>
|
||||
<p class="text-neutral-600">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if editHref}
|
||||
<Button.Root
|
||||
class="bg-neutral-800 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
||||
border border-neutral-600 rounded-full p-4 text-neutral-100"
|
||||
href={editHref}
|
||||
>
|
||||
<IconEdit />
|
||||
</Button.Root>
|
||||
{/if}
|
||||
|
||||
{#if wakePost}
|
||||
<Button.Root
|
||||
class="bg-emerald-600 hover:bg-emerald-800 transition-all duration-300 ease-in-out
|
||||
border border-emerald-500 rounded-full p-4 text-neutral-100 cursor-pointer"
|
||||
>
|
||||
<IconPlay />
|
||||
</Button.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(302, '/dashboard');
|
||||
};
|
||||
redirect(302, '/dash');
|
||||
};
|
||||
|
||||
10
src/routes/dash/+layout.server.ts
Normal file
10
src/routes/dash/+layout.server.ts
Normal file
@ -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),
|
||||
};
|
||||
};
|
||||
98
src/routes/dash/+layout.svelte
Normal file
98
src/routes/dash/+layout.svelte
Normal file
@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||
import NavBar from '$lib/v2/ui/NavBar.svelte';
|
||||
import NavBarLink from '$lib/v2/ui/NavBarLink.svelte';
|
||||
import { Button, DropdownMenu } from 'bits-ui';
|
||||
import IconChevronDown from '~icons/tabler/chevron-down';
|
||||
import IconChevronRight from '~icons/tabler/chevron-right';
|
||||
import IconHome from '~icons/tabler/home';
|
||||
import IconUsers from '~icons/tabler/users';
|
||||
import IconUsersGroup from '~icons/tabler/users-group';
|
||||
import IconDeviceDesktopPin from '~icons/tabler/device-desktop-pin';
|
||||
import IconLogout from '~icons/tabler/logout';
|
||||
import { slideFade } from '$lib/v2/transitions/slideFade.js';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
function isActive(pageName: string) {
|
||||
return page.route.id?.includes(pageName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - {$pageTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 z-50 flex sm:flex-row flex-col
|
||||
p-3 sm:p-6 pb-6 gap-3 text-sm text-neutral-100 bg-linear-to-b from-neutral-900 via-neutral-900 to-transparent"
|
||||
>
|
||||
<NavBar>
|
||||
<NavBarLink Icon={IconHome} active={isActive('devices')} href="/dash/devices"
|
||||
>Devices</NavBarLink
|
||||
>
|
||||
<NavBarLink Icon={IconUsers} active={isActive('users')} href="/dash/users">Users</NavBarLink>
|
||||
<NavBarLink Icon={IconUsersGroup} active={isActive('groups')} href="/dash/groups"
|
||||
>Groups</NavBarLink
|
||||
>
|
||||
</NavBar>
|
||||
<div class="flex grow">
|
||||
<div
|
||||
class="flex grow items-center gap-3 text-neutral-100 text-sm sm:text-base font-light shadow"
|
||||
>
|
||||
<IconChevronRight />
|
||||
<p>{$pageTitle}</p>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="rounded-full bg-neutral-950 border border-neutral-600 p-1">
|
||||
<Button.Root
|
||||
class="flex items-center p-1 cursor-pointer rounded-full
|
||||
pl-3 pr-2 py-1 gap-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span><span class="hidden md:inline">Signed in as</span> {data.user.name}</span>
|
||||
<IconChevronDown />
|
||||
</Button.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
forceMount
|
||||
class="z-1 bg-neutral-950 border border-neutral-600
|
||||
m-3 p-2 flex flex-col gap-2 rounded-2xl shadow-lg"
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:slideFade>
|
||||
<DropdownMenu.Item>
|
||||
<a
|
||||
href="/sessions"
|
||||
class="px-3 py-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
||||
rounded-lg flex items-center justify-between gap-4"
|
||||
>
|
||||
<p>Sessions</p>
|
||||
<IconDeviceDesktopPin />
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<a
|
||||
data-sveltekit-preload-data="tap"
|
||||
href="/logout"
|
||||
class="px-3 py-2 hover:bg-neutral-700 transition-all duration-300 ease-in-out
|
||||
rounded-lg flex items-center justify-between gap-4"
|
||||
>
|
||||
<p>Logout</p>
|
||||
<IconLogout />
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-svh bg-neutral-900 overflow-y-scroll sm:pt-20 pt-28">
|
||||
{@render children()}
|
||||
</div>
|
||||
6
src/routes/dash/+page.server.ts
Normal file
6
src/routes/dash/+page.server.ts
Normal file
@ -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');
|
||||
};
|
||||
9
src/routes/dash/devices/+page.server.ts
Normal file
9
src/routes/dash/devices/+page.server.ts
Normal file
@ -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),
|
||||
};
|
||||
};
|
||||
22
src/routes/dash/devices/+page.svelte
Normal file
22
src/routes/dash/devices/+page.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
||||
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
$pageTitle = 'Listing all devices';
|
||||
</script>
|
||||
|
||||
<ListPage createHref={data.user.admin ? '/dash/devices/new' : null} msgAdd="Add Device">
|
||||
<div class="flex gap-4 flex-wrap justify-center">
|
||||
{#each data.devices as device}
|
||||
<ResCard
|
||||
title={device.name}
|
||||
subtitle={device.mac}
|
||||
editHref={data.user.admin ? `/dash/devices/${device.id}` : null}
|
||||
wakePost={`/dash/devices/${device.id}/wake`}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</ListPage>
|
||||
119
src/routes/dash/devices/[id]/+page.server.ts
Normal file
119
src/routes/dash/devices/[id]/+page.server.ts
Normal file
@ -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;
|
||||
73
src/routes/dash/devices/[id]/+page.svelte
Normal file
73
src/routes/dash/devices/[id]/+page.svelte
Normal file
@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import InputText from '$lib/v2/forms/InputText.svelte';
|
||||
import { pageTitle } from '$lib/v2/globalStores';
|
||||
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
||||
import Collapsible from '$lib/v2/ui/Collapsible.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
$pageTitle = data.device ? 'Editing device: ' + data.device.name : 'Adding new Device';
|
||||
|
||||
// store the state of data that can be collapsed on the form and bind to it
|
||||
// as to not lose user edits when the form is collapsed and expanded again
|
||||
let collapsibleData = $state({
|
||||
broadcast: data.device?.broadcast || '255.255.255.255',
|
||||
port: data.device?.port || '9',
|
||||
packets: data.device?.packets || '3',
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success && browser) {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<EditPage createOnly={!data.device}>
|
||||
<InputText
|
||||
name="name"
|
||||
label="Name"
|
||||
description="How the device will appear in the dashboard"
|
||||
value={data.device?.name}
|
||||
placeholder="New Device"
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputText
|
||||
name="mac"
|
||||
label="MAC Address"
|
||||
description="Address used for the Wake-On-Lan packets"
|
||||
value={data.device?.mac}
|
||||
placeholder="00:00:00:00:00:00"
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<Collapsible label="Advanced" class="mb-6" renderHidden>
|
||||
<InputText
|
||||
name="broadcast"
|
||||
label="Broadcast IP"
|
||||
bind:value={collapsibleData.broadcast}
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputText
|
||||
name="port"
|
||||
label="Broadcast Port"
|
||||
bind:value={collapsibleData.port}
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputText
|
||||
name="packets"
|
||||
label="Packets Quantity"
|
||||
bind:value={collapsibleData.packets}
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
</Collapsible>
|
||||
</EditPage>
|
||||
16
src/routes/dash/groups/+page.server.ts
Normal file
16
src/routes/dash/groups/+page.server.ts
Normal file
@ -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<string, number>,
|
||||
),
|
||||
};
|
||||
};
|
||||
21
src/routes/dash/groups/+page.svelte
Normal file
21
src/routes/dash/groups/+page.svelte
Normal file
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
||||
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
$pageTitle = 'Listing all groups';
|
||||
</script>
|
||||
|
||||
<ListPage createHref={data.user.admin ? '/dash/groups/new' : null} msgAdd="Add Group">
|
||||
<div class="flex gap-4 flex-wrap justify-center">
|
||||
{#each data.groups as group}
|
||||
<ResCard
|
||||
title={group.name}
|
||||
subtitle="{data.userCounts[group.id]} users, {group.devices.length} devices"
|
||||
editHref={data.user.admin ? `/dash/groups/${group.id}` : null}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</ListPage>
|
||||
83
src/routes/dash/groups/[id]/+page.server.ts
Normal file
83
src/routes/dash/groups/[id]/+page.server.ts
Normal file
@ -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;
|
||||
41
src/routes/dash/groups/[id]/+page.svelte
Normal file
41
src/routes/dash/groups/[id]/+page.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import InputCombobox from '$lib/v2/forms/InputCombobox.svelte';
|
||||
import InputText from '$lib/v2/forms/InputText.svelte';
|
||||
import { pageTitle } from '$lib/v2/globalStores';
|
||||
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
$pageTitle = data.group ? 'Editing group: ' + data.group.name : 'Adding new group';
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success && browser) {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<EditPage createOnly={!data.group}>
|
||||
<InputText
|
||||
name="name"
|
||||
label="Name"
|
||||
description="How the group will appear in the dashboard"
|
||||
value={data.group?.name}
|
||||
placeholder="New Group"
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputCombobox
|
||||
label="Devices"
|
||||
description="Devices that users who are part of this group will be able to wake"
|
||||
name="devices"
|
||||
class="w-full"
|
||||
data={data.devices.map((d) => ({
|
||||
value: d.id,
|
||||
name: d.name,
|
||||
selected: data.group?.devices.find((devId) => devId === d.id) ? true : false,
|
||||
}))}
|
||||
/>
|
||||
</EditPage>
|
||||
11
src/routes/dash/users/+page.server.ts
Normal file
11
src/routes/dash/users/+page.server.ts
Normal file
@ -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)),
|
||||
};
|
||||
};
|
||||
21
src/routes/dash/users/+page.svelte
Normal file
21
src/routes/dash/users/+page.svelte
Normal file
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { pageTitle } from '$lib/v2/globalStores.js';
|
||||
import ListPage from '$lib/v2/snippets/ListPage.svelte';
|
||||
import ResCard from '$lib/v2/ui/ResCard.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
$pageTitle = 'Listing all users';
|
||||
</script>
|
||||
|
||||
<ListPage createHref={data.user.admin ? '/dash/users/new' : null} msgAdd="Add User">
|
||||
<div class="flex gap-4 flex-wrap justify-center">
|
||||
{#each data.users as user}
|
||||
<ResCard
|
||||
title={user.name}
|
||||
subtitle="{user.devices.length} devices, {user.groups.length} groups"
|
||||
editHref={data.user.admin ? `/dash/users/${user.id}` : null}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</ListPage>
|
||||
111
src/routes/dash/users/[id]/+page.server.ts
Normal file
111
src/routes/dash/users/[id]/+page.server.ts
Normal file
@ -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;
|
||||
71
src/routes/dash/users/[id]/+page.svelte
Normal file
71
src/routes/dash/users/[id]/+page.svelte
Normal file
@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import InputCheckbox from '$lib/v2/forms/InputCheckbox.svelte';
|
||||
import InputCombobox from '$lib/v2/forms/InputCombobox.svelte';
|
||||
import InputText from '$lib/v2/forms/InputText.svelte';
|
||||
import { pageTitle } from '$lib/v2/globalStores';
|
||||
import EditPage from '$lib/v2/snippets/EditPage.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
$pageTitle = data.user ? 'Editing user: ' + data.user.name : 'Adding new user';
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success && browser) {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<EditPage createOnly={!data.user}>
|
||||
<InputText
|
||||
name="name"
|
||||
label="Name"
|
||||
description="How the user will appear in the dashboard"
|
||||
value={data.user?.name}
|
||||
placeholder="New User"
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<InputText
|
||||
name="password"
|
||||
label="Password"
|
||||
description={data.user
|
||||
? "Leave blank to keep the user's current password"
|
||||
: 'The password will be hashed and stored in the database'}
|
||||
type="password"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<InputCheckbox
|
||||
name="admin"
|
||||
label="Administrator"
|
||||
description="Can manage and use all devices, groups and users"
|
||||
checked={data.user?.admin ?? false}
|
||||
/>
|
||||
|
||||
<InputCombobox
|
||||
label="Devices"
|
||||
description="Devices that the user will be able to wake"
|
||||
name="devices"
|
||||
class="w-full"
|
||||
data={data.devices.map((d) => ({
|
||||
value: d.id,
|
||||
name: d.name,
|
||||
selected: data.user?.devices.find((devId) => devId === d.id) ? true : false,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<InputCombobox
|
||||
label="Groups"
|
||||
description="Groups that the user will be a part of and inherit device access from"
|
||||
name="groups"
|
||||
class="w-full"
|
||||
data={data.groups.map((g) => ({
|
||||
value: g.id,
|
||||
name: g.name,
|
||||
selected: data.user?.groups.find((groupId) => groupId === g.id) ? true : false,
|
||||
}))}
|
||||
/>
|
||||
</EditPage>
|
||||
@ -44,6 +44,6 @@ export const actions = {
|
||||
},
|
||||
);
|
||||
|
||||
redirect(302, '/dashboard');
|
||||
redirect(302, '/dash');
|
||||
},
|
||||
} satisfies Actions;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user