From c49692a75bac91683d29a358ff2eb58f0555c4fe Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 16 Feb 2026 13:30:01 +0100 Subject: [PATCH] feat: add user management features with CRUD operations and password policies - Implemented PATCH and DELETE endpoints for bug reports. - Created user management page with user creation, display name update, password reset, and user deletion functionalities. - Added password validation and hashing for user creation and password reset. - Integrated dialogs for user actions (create, update, reset password, delete). - Configured SvelteKit with Node adapter and Vite for build process. - Set up Tailwind CSS for styling. --- .env.example | 6 + .gitignore | 7 + .svelte-kit/ambient.d.ts | 249 +++++++++ .svelte-kit/generated/client/app.js | 37 ++ .svelte-kit/generated/client/matchers.js | 1 + .svelte-kit/generated/client/nodes/0.js | 1 + .svelte-kit/generated/client/nodes/1.js | 1 + .svelte-kit/generated/client/nodes/2.js | 1 + .svelte-kit/generated/client/nodes/3.js | 1 + .svelte-kit/generated/client/nodes/4.js | 0 .svelte-kit/generated/client/nodes/5.js | 1 + .svelte-kit/generated/client/nodes/6.js | 1 + .svelte-kit/generated/root.js | 3 + .svelte-kit/generated/root.svelte | 68 +++ .svelte-kit/generated/server/internal.js | 53 ++ .svelte-kit/non-ambient.d.ts | 57 ++ .svelte-kit/tsconfig.json | 55 ++ .svelte-kit/types/route_meta_data.json | 33 ++ .svelte-kit/types/src/routes/$types.d.ts | 34 ++ .../api/reports/[id]/download/$types.d.ts | 11 + .../reports/[id]/files/[fileId]/$types.d.ts | 11 + .../routes/api/reports/refresh/$types.d.ts | 10 + .../types/src/routes/login/$types.d.ts | 31 ++ .../src/routes/login/proxy+page.server.ts | 61 +++ .../types/src/routes/logout/$types.d.ts | 31 ++ .../src/routes/logout/proxy+page.server.ts | 26 + .../types/src/routes/proxy+layout.server.ts | 26 + .../types/src/routes/proxy+page.server.ts | 71 +++ .../types/src/routes/reports/[id]/$types.d.ts | 27 + .../routes/reports/[id]/proxy+page.server.ts | 45 ++ .../types/src/routes/users/$types.d.ts | 31 ++ .../src/routes/users/proxy+page.server.ts | 217 ++++++++ Dockerfile | 9 + components.json | 16 + drizzle.config.ts | 13 + package.json | 41 ++ src/app.css | 121 +++++ src/app.d.ts | 10 + src/app.html | 12 + src/hooks.server.ts | 60 +++ .../alert-dialog/alert-dialog-action.svelte | 18 + .../alert-dialog/alert-dialog-cancel.svelte | 18 + .../alert-dialog/alert-dialog-content.svelte | 29 + .../alert-dialog-description.svelte | 17 + .../alert-dialog/alert-dialog-footer.svelte | 20 + .../alert-dialog/alert-dialog-header.svelte | 20 + .../alert-dialog/alert-dialog-overlay.svelte | 20 + .../alert-dialog/alert-dialog-portal.svelte | 7 + .../ui/alert-dialog/alert-dialog-title.svelte | 17 + .../alert-dialog/alert-dialog-trigger.svelte | 7 + .../ui/alert-dialog/alert-dialog.svelte | 7 + src/lib/components/ui/alert-dialog/index.ts | 37 ++ src/lib/components/ui/button/button.svelte | 82 +++ src/lib/components/ui/button/index.ts | 17 + src/lib/components/ui/card/card-action.svelte | 20 + .../components/ui/card/card-content.svelte | 15 + .../ui/card/card-description.svelte | 20 + src/lib/components/ui/card/card-footer.svelte | 20 + src/lib/components/ui/card/card-header.svelte | 23 + src/lib/components/ui/card/card-title.svelte | 20 + src/lib/components/ui/card/card.svelte | 23 + src/lib/components/ui/card/index.ts | 25 + .../components/ui/dialog/dialog-close.svelte | 7 + .../ui/dialog/dialog-content.svelte | 45 ++ .../ui/dialog/dialog-description.svelte | 17 + .../components/ui/dialog/dialog-footer.svelte | 20 + .../components/ui/dialog/dialog-header.svelte | 20 + .../ui/dialog/dialog-overlay.svelte | 20 + .../components/ui/dialog/dialog-portal.svelte | 7 + .../components/ui/dialog/dialog-title.svelte | 17 + .../ui/dialog/dialog-trigger.svelte | 7 + src/lib/components/ui/dialog/dialog.svelte | 7 + src/lib/components/ui/dialog/index.ts | 34 ++ .../components/ui/empty/empty-content.svelte | 23 + .../ui/empty/empty-description.svelte | 23 + .../components/ui/empty/empty-header.svelte | 20 + .../components/ui/empty/empty-media.svelte | 41 ++ .../components/ui/empty/empty-title.svelte | 20 + src/lib/components/ui/empty/empty.svelte | 23 + src/lib/components/ui/empty/index.ts | 22 + src/lib/components/ui/input/index.ts | 7 + src/lib/components/ui/input/input.svelte | 52 ++ src/lib/components/ui/label/index.ts | 7 + src/lib/components/ui/label/label.svelte | 20 + src/lib/components/ui/select/index.ts | 37 ++ .../ui/select/select-content.svelte | 45 ++ .../ui/select/select-group-heading.svelte | 21 + .../components/ui/select/select-group.svelte | 7 + .../components/ui/select/select-item.svelte | 38 ++ .../components/ui/select/select-label.svelte | 20 + .../components/ui/select/select-portal.svelte | 7 + .../select/select-scroll-down-button.svelte | 20 + .../ui/select/select-scroll-up-button.svelte | 20 + .../ui/select/select-separator.svelte | 18 + .../ui/select/select-trigger.svelte | 29 + src/lib/components/ui/select/select.svelte | 11 + src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 21 + src/lib/components/ui/sheet/index.ts | 34 ++ .../components/ui/sheet/sheet-close.svelte | 7 + .../components/ui/sheet/sheet-content.svelte | 60 +++ .../ui/sheet/sheet-description.svelte | 17 + .../components/ui/sheet/sheet-footer.svelte | 20 + .../components/ui/sheet/sheet-header.svelte | 20 + .../components/ui/sheet/sheet-overlay.svelte | 20 + .../components/ui/sheet/sheet-portal.svelte | 7 + .../components/ui/sheet/sheet-title.svelte | 17 + .../components/ui/sheet/sheet-trigger.svelte | 7 + src/lib/components/ui/sheet/sheet.svelte | 7 + src/lib/components/ui/sidebar/constants.ts | 6 + .../components/ui/sidebar/context.svelte.ts | 81 +++ src/lib/components/ui/sidebar/index.ts | 75 +++ .../ui/sidebar/sidebar-content.svelte | 24 + .../ui/sidebar/sidebar-footer.svelte | 21 + .../ui/sidebar/sidebar-group-action.svelte | 36 ++ .../ui/sidebar/sidebar-group-content.svelte | 21 + .../ui/sidebar/sidebar-group-label.svelte | 34 ++ .../ui/sidebar/sidebar-group.svelte | 21 + .../ui/sidebar/sidebar-header.svelte | 21 + .../ui/sidebar/sidebar-input.svelte | 21 + .../ui/sidebar/sidebar-inset.svelte | 24 + .../ui/sidebar/sidebar-menu-action.svelte | 43 ++ .../ui/sidebar/sidebar-menu-badge.svelte | 29 + .../ui/sidebar/sidebar-menu-button.svelte | 103 ++++ .../ui/sidebar/sidebar-menu-item.svelte | 21 + .../ui/sidebar/sidebar-menu-skeleton.svelte | 36 ++ .../ui/sidebar/sidebar-menu-sub-button.svelte | 43 ++ .../ui/sidebar/sidebar-menu-sub-item.svelte | 21 + .../ui/sidebar/sidebar-menu-sub.svelte | 25 + .../components/ui/sidebar/sidebar-menu.svelte | 21 + .../ui/sidebar/sidebar-provider.svelte | 53 ++ .../components/ui/sidebar/sidebar-rail.svelte | 36 ++ .../ui/sidebar/sidebar-separator.svelte | 19 + .../ui/sidebar/sidebar-trigger.svelte | 35 ++ src/lib/components/ui/sidebar/sidebar.svelte | 104 ++++ src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 + src/lib/components/ui/table/index.ts | 28 + src/lib/components/ui/table/table-body.svelte | 20 + .../components/ui/table/table-caption.svelte | 20 + src/lib/components/ui/table/table-cell.svelte | 23 + .../components/ui/table/table-footer.svelte | 20 + src/lib/components/ui/table/table-head.svelte | 23 + .../components/ui/table/table-header.svelte | 20 + src/lib/components/ui/table/table-row.svelte | 23 + src/lib/components/ui/table/table.svelte | 22 + src/lib/components/ui/textarea/index.ts | 7 + .../components/ui/textarea/textarea.svelte | 23 + src/lib/components/ui/tooltip/index.ts | 19 + .../ui/tooltip/tooltip-content.svelte | 52 ++ .../ui/tooltip/tooltip-portal.svelte | 7 + .../ui/tooltip/tooltip-provider.svelte | 7 + .../ui/tooltip/tooltip-trigger.svelte | 7 + src/lib/components/ui/tooltip/tooltip.svelte | 7 + src/lib/hooks/is-mobile.svelte.ts | 9 + src/lib/schema.ts | 74 +++ src/lib/server/auth.ts | 38 ++ src/lib/server/db.ts | 16 + src/lib/server/logger.ts | 42 ++ src/lib/stores/presence.svelte.ts | 95 ++++ src/lib/utils.ts | 46 ++ src/routes/+error.svelte | 14 + src/routes/+layout.server.ts | 25 + src/routes/+layout.svelte | 169 ++++++ src/routes/+page.server.ts | 70 +++ src/routes/+page.svelte | 226 ++++++++ src/routes/api/presence/+server.ts | 49 ++ src/routes/api/presence/heartbeat/+server.ts | 25 + src/routes/api/presence/state.ts | 20 + .../api/reports/[id]/download/+server.ts | 68 +++ .../reports/[id]/files/[fileId]/+server.ts | 28 + src/routes/api/reports/refresh/+server.ts | 13 + src/routes/login/+page.server.ts | 63 +++ src/routes/login/+page.svelte | 61 +++ src/routes/logout/+page.server.ts | 24 + src/routes/reports/[id]/+page.server.ts | 45 ++ src/routes/reports/[id]/+page.svelte | 304 +++++++++++ src/routes/reports/[id]/+server.ts | 39 ++ src/routes/users/+page.server.ts | 256 +++++++++ src/routes/users/+page.svelte | 506 ++++++++++++++++++ svelte.config.js | 12 + vite.config.ts | 7 + 182 files changed, 6617 insertions(+) create mode 100644 .env.example create mode 100644 .svelte-kit/ambient.d.ts create mode 100644 .svelte-kit/generated/client/app.js create mode 100644 .svelte-kit/generated/client/matchers.js create mode 100644 .svelte-kit/generated/client/nodes/0.js create mode 100644 .svelte-kit/generated/client/nodes/1.js create mode 100644 .svelte-kit/generated/client/nodes/2.js create mode 100644 .svelte-kit/generated/client/nodes/3.js create mode 100644 .svelte-kit/generated/client/nodes/4.js create mode 100644 .svelte-kit/generated/client/nodes/5.js create mode 100644 .svelte-kit/generated/client/nodes/6.js create mode 100644 .svelte-kit/generated/root.js create mode 100644 .svelte-kit/generated/root.svelte create mode 100644 .svelte-kit/generated/server/internal.js create mode 100644 .svelte-kit/non-ambient.d.ts create mode 100644 .svelte-kit/tsconfig.json create mode 100644 .svelte-kit/types/route_meta_data.json create mode 100644 .svelte-kit/types/src/routes/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/api/reports/[id]/download/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/api/reports/[id]/files/[fileId]/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/api/reports/refresh/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/login/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/login/proxy+page.server.ts create mode 100644 .svelte-kit/types/src/routes/logout/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/logout/proxy+page.server.ts create mode 100644 .svelte-kit/types/src/routes/proxy+layout.server.ts create mode 100644 .svelte-kit/types/src/routes/proxy+page.server.ts create mode 100644 .svelte-kit/types/src/routes/reports/[id]/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/reports/[id]/proxy+page.server.ts create mode 100644 .svelte-kit/types/src/routes/users/$types.d.ts create mode 100644 .svelte-kit/types/src/routes/users/proxy+page.server.ts create mode 100644 Dockerfile create mode 100644 components.json create mode 100644 drizzle.config.ts create mode 100644 package.json create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/hooks.server.ts create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-action.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-content.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-description.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-header.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-title.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog.svelte create mode 100644 src/lib/components/ui/alert-dialog/index.ts create mode 100644 src/lib/components/ui/button/button.svelte create mode 100644 src/lib/components/ui/button/index.ts create mode 100644 src/lib/components/ui/card/card-action.svelte create mode 100644 src/lib/components/ui/card/card-content.svelte create mode 100644 src/lib/components/ui/card/card-description.svelte create mode 100644 src/lib/components/ui/card/card-footer.svelte create mode 100644 src/lib/components/ui/card/card-header.svelte create mode 100644 src/lib/components/ui/card/card-title.svelte create mode 100644 src/lib/components/ui/card/card.svelte create mode 100644 src/lib/components/ui/card/index.ts create mode 100644 src/lib/components/ui/dialog/dialog-close.svelte create mode 100644 src/lib/components/ui/dialog/dialog-content.svelte create mode 100644 src/lib/components/ui/dialog/dialog-description.svelte create mode 100644 src/lib/components/ui/dialog/dialog-footer.svelte create mode 100644 src/lib/components/ui/dialog/dialog-header.svelte create mode 100644 src/lib/components/ui/dialog/dialog-overlay.svelte create mode 100644 src/lib/components/ui/dialog/dialog-portal.svelte create mode 100644 src/lib/components/ui/dialog/dialog-title.svelte create mode 100644 src/lib/components/ui/dialog/dialog-trigger.svelte create mode 100644 src/lib/components/ui/dialog/dialog.svelte create mode 100644 src/lib/components/ui/dialog/index.ts create mode 100644 src/lib/components/ui/empty/empty-content.svelte create mode 100644 src/lib/components/ui/empty/empty-description.svelte create mode 100644 src/lib/components/ui/empty/empty-header.svelte create mode 100644 src/lib/components/ui/empty/empty-media.svelte create mode 100644 src/lib/components/ui/empty/empty-title.svelte create mode 100644 src/lib/components/ui/empty/empty.svelte create mode 100644 src/lib/components/ui/empty/index.ts create mode 100644 src/lib/components/ui/input/index.ts create mode 100644 src/lib/components/ui/input/input.svelte create mode 100644 src/lib/components/ui/label/index.ts create mode 100644 src/lib/components/ui/label/label.svelte create mode 100644 src/lib/components/ui/select/index.ts create mode 100644 src/lib/components/ui/select/select-content.svelte create mode 100644 src/lib/components/ui/select/select-group-heading.svelte create mode 100644 src/lib/components/ui/select/select-group.svelte create mode 100644 src/lib/components/ui/select/select-item.svelte create mode 100644 src/lib/components/ui/select/select-label.svelte create mode 100644 src/lib/components/ui/select/select-portal.svelte create mode 100644 src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 src/lib/components/ui/select/select-separator.svelte create mode 100644 src/lib/components/ui/select/select-trigger.svelte create mode 100644 src/lib/components/ui/select/select.svelte create mode 100644 src/lib/components/ui/separator/index.ts create mode 100644 src/lib/components/ui/separator/separator.svelte create mode 100644 src/lib/components/ui/sheet/index.ts create mode 100644 src/lib/components/ui/sheet/sheet-close.svelte create mode 100644 src/lib/components/ui/sheet/sheet-content.svelte create mode 100644 src/lib/components/ui/sheet/sheet-description.svelte create mode 100644 src/lib/components/ui/sheet/sheet-footer.svelte create mode 100644 src/lib/components/ui/sheet/sheet-header.svelte create mode 100644 src/lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 src/lib/components/ui/sheet/sheet-portal.svelte create mode 100644 src/lib/components/ui/sheet/sheet-title.svelte create mode 100644 src/lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 src/lib/components/ui/sheet/sheet.svelte create mode 100644 src/lib/components/ui/sidebar/constants.ts create mode 100644 src/lib/components/ui/sidebar/context.svelte.ts create mode 100644 src/lib/components/ui/sidebar/index.ts create mode 100644 src/lib/components/ui/sidebar/sidebar-content.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-footer.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-action.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-content.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group-label.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-group.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-header.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-input.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-inset.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-action.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-badge.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-button.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-item.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu-sub.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-menu.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-provider.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-rail.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-separator.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar-trigger.svelte create mode 100644 src/lib/components/ui/sidebar/sidebar.svelte create mode 100644 src/lib/components/ui/skeleton/index.ts create mode 100644 src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 src/lib/components/ui/table/index.ts create mode 100644 src/lib/components/ui/table/table-body.svelte create mode 100644 src/lib/components/ui/table/table-caption.svelte create mode 100644 src/lib/components/ui/table/table-cell.svelte create mode 100644 src/lib/components/ui/table/table-footer.svelte create mode 100644 src/lib/components/ui/table/table-head.svelte create mode 100644 src/lib/components/ui/table/table-header.svelte create mode 100644 src/lib/components/ui/table/table-row.svelte create mode 100644 src/lib/components/ui/table/table.svelte create mode 100644 src/lib/components/ui/textarea/index.ts create mode 100644 src/lib/components/ui/textarea/textarea.svelte create mode 100644 src/lib/components/ui/tooltip/index.ts create mode 100644 src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-portal.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-provider.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip.svelte create mode 100644 src/lib/hooks/is-mobile.svelte.ts create mode 100644 src/lib/schema.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/logger.ts create mode 100644 src/lib/stores/presence.svelte.ts create mode 100644 src/lib/utils.ts create mode 100644 src/routes/+error.svelte create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/api/presence/+server.ts create mode 100644 src/routes/api/presence/heartbeat/+server.ts create mode 100644 src/routes/api/presence/state.ts create mode 100644 src/routes/api/reports/[id]/download/+server.ts create mode 100644 src/routes/api/reports/[id]/files/[fileId]/+server.ts create mode 100644 src/routes/api/reports/refresh/+server.ts create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/logout/+page.server.ts create mode 100644 src/routes/reports/[id]/+page.server.ts create mode 100644 src/routes/reports/[id]/+page.svelte create mode 100644 src/routes/reports/[id]/+server.ts create mode 100644 src/routes/users/+page.server.ts create mode 100644 src/routes/users/+page.svelte create mode 100644 svelte.config.js create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e790cc --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# MySQL Connection +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=emly +MYSQL_PASSWORD=change_me_in_production +MYSQL_DATABASE=emly_bugreports diff --git a/.gitignore b/.gitignore index 2309cc8..d427da5 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,10 @@ dist .yarn/install-state.gz .pnp.* +# IDEs +.idea/ +.vscode/ + +# Bun lockfile +bun.lockb +bun.lock \ No newline at end of file diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts new file mode 100644 index 0000000..66a5d2f --- /dev/null +++ b/.svelte-kit/ambient.d.ts @@ -0,0 +1,249 @@ + +// this file is generated — do not edit it + + +/// + +/** + * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured). + * + * _Unlike_ [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * ```ts + * import { API_KEY } from '$env/static/private'; + * ``` + * + * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * + * ``` + * MY_FEATURE_FLAG="" + * ``` + * + * You can override `.env` values from the command line like so: + * + * ```sh + * MY_FEATURE_FLAG="enabled" npm run dev + * ``` + */ +declare module '$env/static/private' { + export const MYSQL_HOST: string; + export const MYSQL_PORT: string; + export const MYSQL_USER: string; + export const MYSQL_PASSWORD: string; + export const MYSQL_DATABASE: string; + export const ACSetupSvcPort: string; + export const ALLUSERSPROFILE: string; + export const AMDRMPATH: string; + export const APPDATA: string; + export const BUN_INSPECT_CONNECT_TO: string; + export const CGO_ENABLED: string; + export const ChocolateyInstall: string; + export const ChocolateyLastPathUpdate: string; + export const CHROME_CRASHPAD_PIPE_NAME: string; + export const CLAUDE_CODE_SSE_PORT: string; + export const COLORTERM: string; + export const CommonProgramFiles: string; + export const CommonProgramW6432: string; + export const COMPUTERNAME: string; + export const ComSpec: string; + export const DriverData: string; + export const EFC_8892_1262719628: string; + export const EFC_8892_1592913036: string; + export const EFC_8892_2283032206: string; + export const EFC_8892_3789132940: string; + export const FPS_BROWSER_APP_PROFILE_STRING: string; + export const FPS_BROWSER_USER_PROFILE_STRING: string; + export const GIT_ASKPASS: string; + export const GIT_INSTALL_ROOT: string; + export const GIT_PAGER: string; + export const GK_GL_ADDR: string; + export const GK_GL_PATH: string; + export const GoLand: string; + export const GOPATH: string; + export const HOMEDRIVE: string; + export const HOMEPATH: string; + export const JAVA_HOME: string; + export const LANG: string; + export const LOCALAPPDATA: string; + export const LOGONSERVER: string; + export const NODE: string; + export const npm_command: string; + export const npm_config_local_prefix: string; + export const npm_config_user_agent: string; + export const npm_execpath: string; + export const npm_lifecycle_event: string; + export const npm_lifecycle_script: string; + export const npm_node_execpath: string; + export const npm_package_json: string; + export const npm_package_name: string; + export const npm_package_version: string; + export const NUMBER_OF_PROCESSORS: string; + export const OneDrive: string; + export const OS: string; + export const Path: string; + export const PATHEXT: string; + export const PORT: string; + export const PROCESSOR_ARCHITECTURE: string; + export const PROCESSOR_IDENTIFIER: string; + export const PROCESSOR_LEVEL: string; + export const PROCESSOR_REVISION: string; + export const ProgramData: string; + export const ProgramFiles: string; + export const ProgramW6432: string; + export const PSModulePath: string; + export const PUBLIC: string; + export const PWD: string; + export const RlsSvcPort: string; + export const SESSIONNAME: string; + export const SystemDrive: string; + export const SystemRoot: string; + export const TEMP: string; + export const TERM_PROGRAM: string; + export const TERM_PROGRAM_VERSION: string; + export const TMP: string; + export const USERDOMAIN: string; + export const USERDOMAIN_ROAMINGPROFILE: string; + export const USERNAME: string; + export const USERPROFILE: string; + export const VSCODE_GIT_ASKPASS_EXTRA_ARGS: string; + export const VSCODE_GIT_ASKPASS_MAIN: string; + export const VSCODE_GIT_ASKPASS_NODE: string; + export const VSCODE_GIT_IPC_HANDLE: string; + export const VSCODE_INJECTION: string; + export const VSCODE_PYTHON_AUTOACTIVATE_GUARD: string; + export const windir: string; +} + +/** + * Similar to [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Values are replaced statically at build time. + * + * ```ts + * import { PUBLIC_BASE_URL } from '$env/static/public'; + * ``` + */ +declare module '$env/static/public' { + +} + +/** + * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured). + * + * This module cannot be imported into client-side code. + * + * ```ts + * import { env } from '$env/dynamic/private'; + * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + * + * > [!NOTE] In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + */ +declare module '$env/dynamic/private' { + export const env: { + MYSQL_HOST: string; + MYSQL_PORT: string; + MYSQL_USER: string; + MYSQL_PASSWORD: string; + MYSQL_DATABASE: string; + ACSetupSvcPort: string; + ALLUSERSPROFILE: string; + AMDRMPATH: string; + APPDATA: string; + BUN_INSPECT_CONNECT_TO: string; + CGO_ENABLED: string; + ChocolateyInstall: string; + ChocolateyLastPathUpdate: string; + CHROME_CRASHPAD_PIPE_NAME: string; + CLAUDE_CODE_SSE_PORT: string; + COLORTERM: string; + CommonProgramFiles: string; + CommonProgramW6432: string; + COMPUTERNAME: string; + ComSpec: string; + DriverData: string; + EFC_8892_1262719628: string; + EFC_8892_1592913036: string; + EFC_8892_2283032206: string; + EFC_8892_3789132940: string; + FPS_BROWSER_APP_PROFILE_STRING: string; + FPS_BROWSER_USER_PROFILE_STRING: string; + GIT_ASKPASS: string; + GIT_INSTALL_ROOT: string; + GIT_PAGER: string; + GK_GL_ADDR: string; + GK_GL_PATH: string; + GoLand: string; + GOPATH: string; + HOMEDRIVE: string; + HOMEPATH: string; + JAVA_HOME: string; + LANG: string; + LOCALAPPDATA: string; + LOGONSERVER: string; + NODE: string; + npm_command: string; + npm_config_local_prefix: string; + npm_config_user_agent: string; + npm_execpath: string; + npm_lifecycle_event: string; + npm_lifecycle_script: string; + npm_node_execpath: string; + npm_package_json: string; + npm_package_name: string; + npm_package_version: string; + NUMBER_OF_PROCESSORS: string; + OneDrive: string; + OS: string; + Path: string; + PATHEXT: string; + PORT: string; + PROCESSOR_ARCHITECTURE: string; + PROCESSOR_IDENTIFIER: string; + PROCESSOR_LEVEL: string; + PROCESSOR_REVISION: string; + ProgramData: string; + ProgramFiles: string; + ProgramW6432: string; + PSModulePath: string; + PUBLIC: string; + PWD: string; + RlsSvcPort: string; + SESSIONNAME: string; + SystemDrive: string; + SystemRoot: string; + TEMP: string; + TERM_PROGRAM: string; + TERM_PROGRAM_VERSION: string; + TMP: string; + USERDOMAIN: string; + USERDOMAIN_ROAMINGPROFILE: string; + USERNAME: string; + USERPROFILE: string; + VSCODE_GIT_ASKPASS_EXTRA_ARGS: string; + VSCODE_GIT_ASKPASS_MAIN: string; + VSCODE_GIT_ASKPASS_NODE: string; + VSCODE_GIT_IPC_HANDLE: string; + VSCODE_INJECTION: string; + VSCODE_PYTHON_AUTOACTIVATE_GUARD: string; + windir: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * Similar to [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + [key: `PUBLIC_${string}`]: string | undefined; + } +} diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js new file mode 100644 index 0000000..5f4643c --- /dev/null +++ b/.svelte-kit/generated/client/app.js @@ -0,0 +1,37 @@ +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1'), + () => import('./nodes/2'), + () => import('./nodes/3'), + () => import('./nodes/4'), + () => import('./nodes/5'), + () => import('./nodes/6') +]; + +export const server_loads = [0]; + +export const dictionary = { + "/": [~2], + "/login": [~3], + "/logout": [~4], + "/reports/[id]": [~5], + "/users": [~6] + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), + + reroute: (() => {}), + transport: {} +}; + +export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); +export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); + +export const hash = false; + +export const decode = (type, value) => decoders[type](value); + +export { default as root } from '../root.js'; \ No newline at end of file diff --git a/.svelte-kit/generated/client/matchers.js b/.svelte-kit/generated/client/matchers.js new file mode 100644 index 0000000..f6bd30a --- /dev/null +++ b/.svelte-kit/generated/client/matchers.js @@ -0,0 +1 @@ +export const matchers = {}; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/0.js b/.svelte-kit/generated/client/nodes/0.js new file mode 100644 index 0000000..fed1375 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/0.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+layout.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js new file mode 100644 index 0000000..ac3c6a5 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/1.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+error.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/2.js b/.svelte-kit/generated/client/nodes/2.js new file mode 100644 index 0000000..1cb4f85 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/2.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+page.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/3.js b/.svelte-kit/generated/client/nodes/3.js new file mode 100644 index 0000000..f2b26cd --- /dev/null +++ b/.svelte-kit/generated/client/nodes/3.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/login/+page.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/4.js b/.svelte-kit/generated/client/nodes/4.js new file mode 100644 index 0000000..e69de29 diff --git a/.svelte-kit/generated/client/nodes/5.js b/.svelte-kit/generated/client/nodes/5.js new file mode 100644 index 0000000..b3840c7 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/5.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/reports/[id]/+page.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/6.js b/.svelte-kit/generated/client/nodes/6.js new file mode 100644 index 0000000..81499df --- /dev/null +++ b/.svelte-kit/generated/client/nodes/6.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/users/+page.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/root.js b/.svelte-kit/generated/root.js new file mode 100644 index 0000000..4d1e892 --- /dev/null +++ b/.svelte-kit/generated/root.js @@ -0,0 +1,3 @@ +import { asClassComponent } from 'svelte/legacy'; +import Root from './root.svelte'; +export default asClassComponent(Root); \ No newline at end of file diff --git a/.svelte-kit/generated/root.svelte b/.svelte-kit/generated/root.svelte new file mode 100644 index 0000000..0795183 --- /dev/null +++ b/.svelte-kit/generated/root.svelte @@ -0,0 +1,68 @@ + + + + +{#if constructors[1]} + {@const Pyramid_0 = constructors[0]} + + + + + + +{:else} + {@const Pyramid_0 = constructors[0]} + + + +{/if} + +{#if mounted} +
+ {#if navigated} + {title} + {/if} +
+{/if} \ No newline at end of file diff --git a/.svelte-kit/generated/server/internal.js b/.svelte-kit/generated/server/internal.js new file mode 100644 index 0000000..2138d55 --- /dev/null +++ b/.svelte-kit/generated/server/internal.js @@ -0,0 +1,53 @@ + +import root from '../root.js'; +import { set_building, set_prerendering } from '__sveltekit/environment'; +import { set_assets } from '$app/paths/internal/server'; +import { set_manifest, set_read_implementation } from '__sveltekit/server'; +import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js'; + +export const options = { + app_template_contains_nonce: false, + async: false, + csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, + csrf_check_origin: true, + csrf_trusted_origins: [], + embedded: false, + env_public_prefix: 'PUBLIC_', + env_private_prefix: '', + hash_routing: false, + hooks: null, // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root, + service_worker: false, + service_worker_options: undefined, + templates: { + app: ({ head, body, assets, nonce, env }) => "\r\n\r\n\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t" + head + "\r\n\t\r\n\t\r\n\t\t
" + body + "
\r\n\t\r\n\r\n", + error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" + }, + version_hash: "12nkzg7" +}; + +export async function get_hooks() { + let handle; + let handleFetch; + let handleError; + let handleValidationError; + let init; + ({ handle, handleFetch, handleError, handleValidationError, init } = await import("../../../src/hooks.server.ts")); + + let reroute; + let transport; + + + return { + handle, + handleFetch, + handleError, + handleValidationError, + init, + reroute, + transport + }; +} + +export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation }; diff --git a/.svelte-kit/non-ambient.d.ts b/.svelte-kit/non-ambient.d.ts new file mode 100644 index 0000000..71028eb --- /dev/null +++ b/.svelte-kit/non-ambient.d.ts @@ -0,0 +1,57 @@ + +// this file is generated — do not edit it + + +declare module "svelte/elements" { + export interface HTMLAttributes { + 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-preload-code'?: + | true + | '' + | 'eager' + | 'viewport' + | 'hover' + | 'tap' + | 'off' + | undefined + | null; + 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null; + 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null; + } +} + +export {}; + + +declare module "$app/types" { + export interface AppTypes { + RouteId(): "/" | "/api" | "/api/reports" | "/api/reports/refresh" | "/api/reports/[id]" | "/api/reports/[id]/download" | "/api/reports/[id]/files" | "/api/reports/[id]/files/[fileId]" | "/login" | "/logout" | "/reports" | "/reports/[id]" | "/users"; + RouteParams(): { + "/api/reports/[id]": { id: string }; + "/api/reports/[id]/download": { id: string }; + "/api/reports/[id]/files": { id: string }; + "/api/reports/[id]/files/[fileId]": { id: string; fileId: string }; + "/reports/[id]": { id: string } + }; + LayoutParams(): { + "/": { id?: string; fileId?: string }; + "/api": { id?: string; fileId?: string }; + "/api/reports": { id?: string; fileId?: string }; + "/api/reports/refresh": Record; + "/api/reports/[id]": { id: string; fileId?: string }; + "/api/reports/[id]/download": { id: string }; + "/api/reports/[id]/files": { id: string; fileId?: string }; + "/api/reports/[id]/files/[fileId]": { id: string; fileId: string }; + "/login": Record; + "/logout": Record; + "/reports": { id?: string }; + "/reports/[id]": { id: string }; + "/users": Record + }; + Pathname(): "/" | "/api" | "/api/" | "/api/reports" | "/api/reports/" | "/api/reports/refresh" | "/api/reports/refresh/" | `/api/reports/${string}` & {} | `/api/reports/${string}/` & {} | `/api/reports/${string}/download` & {} | `/api/reports/${string}/download/` & {} | `/api/reports/${string}/files` & {} | `/api/reports/${string}/files/` & {} | `/api/reports/${string}/files/${string}` & {} | `/api/reports/${string}/files/${string}/` & {} | "/login" | "/login/" | "/logout" | "/logout/" | "/reports" | "/reports/" | `/reports/${string}` & {} | `/reports/${string}/` & {} | "/users" | "/users/"; + ResolvedPathname(): `${"" | `/${string}`}${ReturnType}`; + Asset(): string & {}; + } +} \ No newline at end of file diff --git a/.svelte-kit/tsconfig.json b/.svelte-kit/tsconfig.json new file mode 100644 index 0000000..7692388 --- /dev/null +++ b/.svelte-kit/tsconfig.json @@ -0,0 +1,55 @@ +{ + "compilerOptions": { + "paths": { + "$lib": [ + "../src/lib" + ], + "$lib/*": [ + "../src/lib/*" + ], + "$app/types": [ + "./types/index.d.ts" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "bundler", + "module": "esnext", + "noEmit": true, + "target": "esnext" + }, + "include": [ + "ambient.d.ts", + "non-ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../test/**/*.js", + "../test/**/*.ts", + "../test/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "../src/service-worker.js", + "../src/service-worker/**/*.js", + "../src/service-worker.ts", + "../src/service-worker/**/*.ts", + "../src/service-worker.d.ts", + "../src/service-worker/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/.svelte-kit/types/route_meta_data.json b/.svelte-kit/types/route_meta_data.json new file mode 100644 index 0000000..50248da --- /dev/null +++ b/.svelte-kit/types/route_meta_data.json @@ -0,0 +1,33 @@ +{ + "/": [ + "src/routes/+page.server.ts", + "src/routes/+layout.server.ts", + "src/routes/+layout.server.ts" + ], + "/api/reports/refresh": [ + "src/routes/api/reports/refresh/+server.ts" + ], + "/api/reports/[id]/download": [ + "src/routes/api/reports/[id]/download/+server.ts" + ], + "/api/reports/[id]/files/[fileId]": [ + "src/routes/api/reports/[id]/files/[fileId]/+server.ts" + ], + "/login": [ + "src/routes/login/+page.server.ts", + "src/routes/+layout.server.ts" + ], + "/logout": [ + "src/routes/logout/+page.server.ts", + "src/routes/+layout.server.ts" + ], + "/reports/[id]": [ + "src/routes/reports/[id]/+page.server.ts", + "src/routes/+layout.server.ts", + "src/routes/reports/[id]/+server.ts" + ], + "/users": [ + "src/routes/users/+page.server.ts", + "src/routes/+layout.server.ts" + ] +} \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/$types.d.ts b/.svelte-kit/types/src/routes/$types.d.ts new file mode 100644 index 0000000..3f8bbc3 --- /dev/null +++ b/.svelte-kit/types/src/routes/$types.d.ts @@ -0,0 +1,34 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; +type PageParentData = EnsureDefined; +type LayoutRouteId = RouteId | "/" | "/login" | "/logout" | "/reports/[id]" | "/users" | null +type LayoutParams = RouteParams & { id?: string } +type LayoutServerParentData = EnsureDefined<{}>; +type LayoutParentData = EnsureDefined<{}>; + +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +export type ActionData = unknown; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type LayoutServerLoad & Record | void = Partial & Record | void> = Kit.ServerLoad; +export type LayoutServerLoadEvent = Parameters[0]; +export type LayoutServerData = Expand>>>>>; +export type LayoutData = Expand & EnsureDefined>; +export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/reports/[id]/download/$types.d.ts b/.svelte-kit/types/src/routes/api/reports/[id]/download/$types.d.ts new file mode 100644 index 0000000..ad59393 --- /dev/null +++ b/.svelte-kit/types/src/routes/api/reports/[id]/download/$types.d.ts @@ -0,0 +1,11 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/api/reports/[id]/download'; + +export type EntryGenerator = () => Promise> | Array; +export type RequestHandler = Kit.RequestHandler; +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/reports/[id]/files/[fileId]/$types.d.ts b/.svelte-kit/types/src/routes/api/reports/[id]/files/[fileId]/$types.d.ts new file mode 100644 index 0000000..103b428 --- /dev/null +++ b/.svelte-kit/types/src/routes/api/reports/[id]/files/[fileId]/$types.d.ts @@ -0,0 +1,11 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string; fileId: string }; +type RouteId = '/api/reports/[id]/files/[fileId]'; + +export type EntryGenerator = () => Promise> | Array; +export type RequestHandler = Kit.RequestHandler; +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/reports/refresh/$types.d.ts b/.svelte-kit/types/src/routes/api/reports/refresh/$types.d.ts new file mode 100644 index 0000000..81e7b76 --- /dev/null +++ b/.svelte-kit/types/src/routes/api/reports/refresh/$types.d.ts @@ -0,0 +1,10 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/api/reports/refresh'; + +export type RequestHandler = Kit.RequestHandler; +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/login/$types.d.ts b/.svelte-kit/types/src/routes/login/$types.d.ts new file mode 100644 index 0000000..4cf42d4 --- /dev/null +++ b/.svelte-kit/types/src/routes/login/$types.d.ts @@ -0,0 +1,31 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/login'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; +type PageParentData = EnsureDefined; + +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +type ExcludeActionFailure = T extends Kit.ActionFailure ? never : T extends void ? never : T; +type ActionsSuccess any>> = { [Key in keyof T]: ExcludeActionFailure>>; }[keyof T]; +type ExtractActionFailure = T extends Kit.ActionFailure ? X extends void ? never : X : never; +type ActionsFailure any>> = { [Key in keyof T]: Exclude>>, void>; }[keyof T]; +type ActionsExport = typeof import('./proxy+page.server.js').actions +export type SubmitFunction = Kit.SubmitFunction>, Expand>> +export type ActionData = Expand> | null; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/login/proxy+page.server.ts b/.svelte-kit/types/src/routes/login/proxy+page.server.ts new file mode 100644 index 0000000..66fa6a7 --- /dev/null +++ b/.svelte-kit/types/src/routes/login/proxy+page.server.ts @@ -0,0 +1,61 @@ +// @ts-nocheck +import type { Actions, PageServerLoad } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { verify } from '@node-rs/argon2'; +import { lucia } from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import { userTable } from '$lib/schema'; +import { eq } from 'drizzle-orm'; + +export const load = async ({ locals }: Parameters[0]) => { + if (locals.user) { + redirect(302, '/'); + } +}; + +export const actions = { + default: async ({ request, cookies }: import('./$types').RequestEvent) => { + const formData = await request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (typeof username !== 'string' || typeof password !== 'string') { + return fail(400, { message: 'Invalid input' }); + } + + if (!username || !password) { + return fail(400, { message: 'Username and password are required' }); + } + + const [user] = await db + .select() + .from(userTable) + .where(eq(userTable.username, username)) + .limit(1); + + if (!user) { + return fail(400, { message: 'Invalid username or password' }); + } + + const validPassword = await verify(user.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + + if (!validPassword) { + return fail(400, { message: 'Invalid username or password' }); + } + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + redirect(302, '/'); + } +}; +;null as any as Actions; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/logout/$types.d.ts b/.svelte-kit/types/src/routes/logout/$types.d.ts new file mode 100644 index 0000000..0cc5220 --- /dev/null +++ b/.svelte-kit/types/src/routes/logout/$types.d.ts @@ -0,0 +1,31 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/logout'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; +type PageParentData = EnsureDefined; + +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +type ExcludeActionFailure = T extends Kit.ActionFailure ? never : T extends void ? never : T; +type ActionsSuccess any>> = { [Key in keyof T]: ExcludeActionFailure>>; }[keyof T]; +type ExtractActionFailure = T extends Kit.ActionFailure ? X extends void ? never : X : never; +type ActionsFailure any>> = { [Key in keyof T]: Exclude>>, void>; }[keyof T]; +type ActionsExport = typeof import('./proxy+page.server.js').actions +export type SubmitFunction = Kit.SubmitFunction>, Expand>> +export type ActionData = Expand> | null; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/logout/proxy+page.server.ts b/.svelte-kit/types/src/routes/logout/proxy+page.server.ts new file mode 100644 index 0000000..91ef800 --- /dev/null +++ b/.svelte-kit/types/src/routes/logout/proxy+page.server.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import type { Actions, PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { lucia } from '$lib/server/auth'; + +export const load = async () => { + redirect(302, '/'); +}; + +export const actions = { + default: async ({ locals, cookies }: import('./$types').RequestEvent) => { + if (!locals.session) { + redirect(302, '/login'); + } + + await lucia.invalidateSession(locals.session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + redirect(302, '/login'); + } +}; +;null as any as PageServerLoad;;null as any as Actions; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/proxy+layout.server.ts b/.svelte-kit/types/src/routes/proxy+layout.server.ts new file mode 100644 index 0000000..c12fd45 --- /dev/null +++ b/.svelte-kit/types/src/routes/proxy+layout.server.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { bugReports } from '$lib/schema'; +import { eq, count } from 'drizzle-orm'; + +export const load = async ({ locals, url }: Parameters[0]) => { + if (url.pathname === '/login') { + return { newCount: 0, user: null }; + } + + if (!locals.user) { + redirect(302, '/login'); + } + + const [result] = await db + .select({ count: count() }) + .from(bugReports) + .where(eq(bugReports.status, 'new')); + + return { + newCount: result.count, + user: locals.user + }; +}; diff --git a/.svelte-kit/types/src/routes/proxy+page.server.ts b/.svelte-kit/types/src/routes/proxy+page.server.ts new file mode 100644 index 0000000..2f5c146 --- /dev/null +++ b/.svelte-kit/types/src/routes/proxy+page.server.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq, like, or, count, sql, desc, and } from 'drizzle-orm'; + +export const load = async ({ url }: Parameters[0]) => { + const page = Math.max(1, Number(url.searchParams.get('page')) || 1); + const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20)); + const status = url.searchParams.get('status') || ''; + const search = url.searchParams.get('search') || ''; + + const conditions = []; + + if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) { + conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed')); + } + + if (search) { + conditions.push( + or( + like(bugReports.hostname, `%${search}%`), + like(bugReports.os_user, `%${search}%`), + like(bugReports.name, `%${search}%`), + like(bugReports.email, `%${search}%`) + ) + ); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + // Get total count + const [{ total }] = await db + .select({ total: count() }) + .from(bugReports) + .where(where); + + // Get paginated reports with file count + const reports = await db + .select({ + id: bugReports.id, + name: bugReports.name, + email: bugReports.email, + hostname: bugReports.hostname, + os_user: bugReports.os_user, + status: bugReports.status, + created_at: bugReports.created_at, + file_count: count(bugReportFiles.id) + }) + .from(bugReports) + .leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id)) + .where(where) + .groupBy(bugReports.id) + .orderBy(desc(bugReports.created_at)) + .limit(pageSize) + .offset((page - 1) * pageSize); + + return { + reports, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize) + }, + filters: { + status, + search + } + }; +}; diff --git a/.svelte-kit/types/src/routes/reports/[id]/$types.d.ts b/.svelte-kit/types/src/routes/reports/[id]/$types.d.ts new file mode 100644 index 0000000..79b0517 --- /dev/null +++ b/.svelte-kit/types/src/routes/reports/[id]/$types.d.ts @@ -0,0 +1,27 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/reports/[id]'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; +type PageParentData = EnsureDefined; + +export type EntryGenerator = () => Promise> | Array; +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +export type ActionData = unknown; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type RequestHandler = Kit.RequestHandler; +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/reports/[id]/proxy+page.server.ts b/.svelte-kit/types/src/routes/reports/[id]/proxy+page.server.ts new file mode 100644 index 0000000..3b84df5 --- /dev/null +++ b/.svelte-kit/types/src/routes/reports/[id]/proxy+page.server.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; + +export const load = async ({ params }: Parameters[0]) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const [report] = await db + .select() + .from(bugReports) + .where(eq(bugReports.id, id)) + .limit(1); + + if (!report) throw error(404, 'Report not found'); + + const files = await db + .select({ + id: bugReportFiles.id, + report_id: bugReportFiles.report_id, + file_role: bugReportFiles.file_role, + filename: bugReportFiles.filename, + mime_type: bugReportFiles.mime_type, + file_size: bugReportFiles.file_size, + created_at: bugReportFiles.created_at + }) + .from(bugReportFiles) + .where(eq(bugReportFiles.report_id, id)); + + return { + report: { + ...report, + system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null, + created_at: report.created_at.toISOString(), + updated_at: report.updated_at.toISOString() + }, + files: files.map((f) => ({ + ...f, + created_at: f.created_at.toISOString() + })) + }; +}; diff --git a/.svelte-kit/types/src/routes/users/$types.d.ts b/.svelte-kit/types/src/routes/users/$types.d.ts new file mode 100644 index 0000000..f9d29a5 --- /dev/null +++ b/.svelte-kit/types/src/routes/users/$types.d.ts @@ -0,0 +1,31 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/users'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; +type PageParentData = EnsureDefined; + +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +type ExcludeActionFailure = T extends Kit.ActionFailure ? never : T extends void ? never : T; +type ActionsSuccess any>> = { [Key in keyof T]: ExcludeActionFailure>>; }[keyof T]; +type ExtractActionFailure = T extends Kit.ActionFailure ? X extends void ? never : X : never; +type ActionsFailure any>> = { [Key in keyof T]: Exclude>>, void>; }[keyof T]; +type ActionsExport = typeof import('./proxy+page.server.js').actions +export type SubmitFunction = Kit.SubmitFunction>, Expand>> +export type ActionData = Expand> | null; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/users/proxy+page.server.ts b/.svelte-kit/types/src/routes/users/proxy+page.server.ts new file mode 100644 index 0000000..71ba422 --- /dev/null +++ b/.svelte-kit/types/src/routes/users/proxy+page.server.ts @@ -0,0 +1,217 @@ +// @ts-nocheck +import type { Actions, PageServerLoad } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { hash } from '@node-rs/argon2'; +import { generateIdFromEntropySize } from 'lucia'; +import { db } from '$lib/server/db'; +import { userTable } from '$lib/schema'; +import { eq } from 'drizzle-orm'; + +const PASSWORD_MIN_LENGTH = 8; +const PASSWORD_MAX_LENGTH = 255; + +function validatePassword(password: string): string | null { + if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) { + return `Password must be ${PASSWORD_MIN_LENGTH}-${PASSWORD_MAX_LENGTH} characters`; + } + if (!/[A-Z]/.test(password)) { + return 'Password must contain at least one uppercase letter'; + } + if (!/[a-z]/.test(password)) { + return 'Password must contain at least one lowercase letter'; + } + if (!/[0-9]/.test(password)) { + return 'Password must contain at least one number'; + } + if (!/[^A-Za-z0-9]/.test(password)) { + return 'Password must contain at least one special character'; + } + return null; +} + +async function hashPassword(password: string): Promise { + return hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); +} + +export const load = async ({ locals }: Parameters[0]) => { + if (!locals.user || locals.user.role !== 'admin') { + redirect(302, '/'); + } + + const users = await db + .select({ + id: userTable.id, + username: userTable.username, + displayname: userTable.displayname, + role: userTable.role, + createdAt: userTable.createdAt + }) + .from(userTable) + .orderBy(userTable.createdAt); + + return { users }; +}; + +export const actions = { + create: async ({ request, locals }: import('./$types').RequestEvent) => { + if (!locals.user || locals.user.role !== 'admin') { + return fail(403, { message: 'Unauthorized' }); + } + + const formData = await request.formData(); + const username = formData.get('username'); + const displayname = formData.get('displayname') || ''; + const password = formData.get('password'); + const confirmPassword = formData.get('confirmPassword'); + const role = formData.get('role'); + + if ( + typeof username !== 'string' || + typeof displayname !== 'string' || + typeof password !== 'string' || + typeof confirmPassword !== 'string' || + typeof role !== 'string' + ) { + return fail(400, { message: 'Invalid input' }); + } + + if (!username || !password) { + return fail(400, { message: 'Username and password are required' }); + } + + if (username.length < 3 || username.length > 255) { + return fail(400, { message: 'Username must be 3-255 characters' }); + } + + if (password !== confirmPassword) { + return fail(400, { message: 'Passwords do not match' }); + } + + const passwordError = validatePassword(password); + if (passwordError) { + return fail(400, { message: passwordError }); + } + + if (role !== 'admin' && role !== 'user') { + return fail(400, { message: 'Invalid role' }); + } + + // Check if username already exists + const [existing] = await db + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.username, username)) + .limit(1); + + if (existing) { + return fail(400, { message: 'Username already exists' }); + } + + const passwordHash = await hashPassword(password); + const userId = generateIdFromEntropySize(10); + + await db.insert(userTable).values({ + id: userId, + username, + displayname, + passwordHash, + role: role as 'admin' | 'user' + }); + + return { success: true }; + }, + + updateDisplayname: async ({ request, locals }: import('./$types').RequestEvent) => { + if (!locals.user || locals.user.role !== 'admin') { + return fail(403, { message: 'Unauthorized' }); + } + + const formData = await request.formData(); + const userId = formData.get('userId'); + const displayname = formData.get('displayname'); + + if (typeof userId !== 'string' || typeof displayname !== 'string') { + return fail(400, { message: 'Invalid input' }); + } + + await db.update(userTable).set({ displayname }).where(eq(userTable.id, userId)); + + return { success: true }; + }, + + resetPassword: async ({ request, locals }: import('./$types').RequestEvent) => { + if (!locals.user || locals.user.role !== 'admin') { + return fail(403, { message: 'Unauthorized' }); + } + + const formData = await request.formData(); + const userId = formData.get('userId'); + + if (typeof userId === 'string' && userId === locals.user.id) { + return fail(400, { message: 'Cannot reset your own password from here' }); + } + const newPassword = formData.get('newPassword'); + const confirmPassword = formData.get('confirmPassword'); + + if ( + typeof userId !== 'string' || + typeof newPassword !== 'string' || + typeof confirmPassword !== 'string' + ) { + return fail(400, { message: 'Invalid input' }); + } + + if (newPassword !== confirmPassword) { + return fail(400, { message: 'Passwords do not match' }); + } + + const passwordError = validatePassword(newPassword); + if (passwordError) { + return fail(400, { message: passwordError }); + } + + const passwordHash = await hashPassword(newPassword); + + await db.update(userTable).set({ passwordHash }).where(eq(userTable.id, userId)); + + return { success: true }; + }, + + delete: async ({ request, locals }: import('./$types').RequestEvent) => { + if (!locals.user || locals.user.role !== 'admin') { + return fail(403, { message: 'Unauthorized' }); + } + + const formData = await request.formData(); + const userId = formData.get('userId'); + + if (typeof userId !== 'string') { + return fail(400, { message: 'Invalid input' }); + } + + if (userId === locals.user.id) { + return fail(400, { message: 'Cannot delete your own account' }); + } + + // Prevent deleting admin users + const [targetUser] = await db + .select({ role: userTable.role }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1); + + if (targetUser?.role === 'admin') { + return fail(400, { message: 'Cannot delete an admin user' }); + } + + await db.delete(userTable).where(eq(userTable.id, userId)); + + return { success: true }; + } +}; +;null as any as Actions; \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e67551 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM oven/bun:alpine +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install +COPY . . +RUN bun run build +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["bun", "build/index.js"] diff --git a/components.json b/components.json new file mode 100644 index 0000000..e44a4eb --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src\\app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..2c0ea05 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/lib/schema.ts', + dialect: 'mysql', + dbCredentials: { + host: process.env.MYSQL_HOST || 'localhost', + port: Number(process.env.MYSQL_PORT) || 3306, + user: process.env.MYSQL_USER || 'emly', + password: process.env.MYSQL_PASSWORD || '', + database: process.env.MYSQL_DATABASE || 'emly_bugreports' + } +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f043c37 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "emly-dashboard", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev --port 3001", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@internationalized/date": "^3.11.0", + "@lucide/svelte": "^0.561.0", + "@sveltejs/adapter-node": "^5.5.3", + "@sveltejs/kit": "^2.52.0", + "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.2.3", + "drizzle-kit": "^0.31.9", + "svelte": "^5.51.2", + "svelte-check": "^4.4.0", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^6.4.1" + }, + "dependencies": { + "@lucia-auth/adapter-drizzle": "^1.1.0", + "@node-rs/argon2": "^2.0.2", + "drizzle-orm": "^0.38.4", + "lucia": "^3.2.2", + "mysql2": "^3.17.1", + "bits-ui": "^2.15.5", + "clsx": "^2.1.1", + "tailwind-merge": "^3.4.1", + "tailwind-variants": "^3.2.2", + "jszip": "^3.10.1", + "lucide-svelte": "^0.469.0" + }, + "type": "module" +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..ac256d3 --- /dev/null +++ b/src/app.css @@ -0,0 +1,121 @@ +@import "tailwindcss"; + +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..c0087e0 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,10 @@ +declare global { + namespace App { + interface Locals { + user: import('lucia').User | null; + session: import('lucia').Session | null; + } + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..a2e03b2 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..0909941 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,60 @@ +import type { Handle } from '@sveltejs/kit'; +import { lucia } from '$lib/server/auth'; +import { initLogger, Log } from '$lib/server/logger'; + +// Initialize dashboard logger +initLogger(); + +export const handle: Handle = async ({ event, resolve }) => { + const ip = + event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + event.request.headers.get('x-real-ip') || + event.getClientAddress?.() || + 'unknown'; + Log('HTTP', `${event.request.method} ${event.url.pathname} from ${ip}`); + + const sessionId = event.cookies.get(lucia.sessionCookieName); + + if (!sessionId) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = await lucia.validateSession(sessionId); + + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + } + + if (!session) { + Log('AUTH', `Invalid session from ip=${ip}`); + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + } + + // If user is disabled, invalidate their session and clear cookie + if (session && user && !user.enabled) { + Log('AUTH', `Disabled user rejected: username=${user.username} ip=${ip}`); + await lucia.invalidateSession(session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + event.locals.user = user; + event.locals.session = session; + return resolve(event); +}; diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 0000000..a005691 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 0000000..a7b0cf7 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 0000000..236bcad --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,29 @@ + + + + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte new file mode 100644 index 0000000..2ec67dc --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte new file mode 100644 index 0000000..f78b97a --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte new file mode 100644 index 0000000..1835d91 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte new file mode 100644 index 0000000..a64ee76 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte new file mode 100644 index 0000000..f0a19a8 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte new file mode 100644 index 0000000..7ef2b5f --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte new file mode 100644 index 0000000..b22d1d5 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog.svelte b/src/lib/components/ui/alert-dialog/alert-dialog.svelte new file mode 100644 index 0000000..7ea78bb --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/index.ts b/src/lib/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..269538e --- /dev/null +++ b/src/lib/components/ui/alert-dialog/index.ts @@ -0,0 +1,37 @@ +import Root from "./alert-dialog.svelte"; +import Portal from "./alert-dialog-portal.svelte"; +import Trigger from "./alert-dialog-trigger.svelte"; +import Title from "./alert-dialog-title.svelte"; +import Action from "./alert-dialog-action.svelte"; +import Cancel from "./alert-dialog-cancel.svelte"; +import Footer from "./alert-dialog-footer.svelte"; +import Header from "./alert-dialog-header.svelte"; +import Overlay from "./alert-dialog-overlay.svelte"; +import Content from "./alert-dialog-content.svelte"; +import Description from "./alert-dialog-description.svelte"; + +export { + Root, + Title, + Action, + Cancel, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + // + Root as AlertDialog, + Title as AlertDialogTitle, + Action as AlertDialogAction, + Cancel as AlertDialogCancel, + Portal as AlertDialogPortal, + Footer as AlertDialogFooter, + Header as AlertDialogHeader, + Trigger as AlertDialogTrigger, + Overlay as AlertDialogOverlay, + Content as AlertDialogContent, + Description as AlertDialogDescription, +}; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..a8296ae --- /dev/null +++ b/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/src/lib/components/ui/card/card-action.svelte b/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..2d4d0f2 --- /dev/null +++ b/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..2501788 --- /dev/null +++ b/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..7447231 --- /dev/null +++ b/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/src/lib/components/ui/dialog/dialog-close.svelte b/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..840b2f6 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..5c6ee6d --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,45 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3845023 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..e7ff446 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..4e5c447 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f81ad83 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-portal.svelte b/src/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..ccfa79c --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..e4d4b34 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-trigger.svelte b/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..9d1e801 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog.svelte b/src/lib/components/ui/dialog/dialog.svelte new file mode 100644 index 0000000..211672c --- /dev/null +++ b/src/lib/components/ui/dialog/dialog.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..076cef5 --- /dev/null +++ b/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,34 @@ +import Root from "./dialog.svelte"; +import Portal from "./dialog-portal.svelte"; +import Title from "./dialog-title.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Trigger from "./dialog-trigger.svelte"; +import Close from "./dialog-close.svelte"; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/src/lib/components/ui/empty/empty-content.svelte b/src/lib/components/ui/empty/empty-content.svelte new file mode 100644 index 0000000..f5a9c68 --- /dev/null +++ b/src/lib/components/ui/empty/empty-content.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/empty/empty-description.svelte b/src/lib/components/ui/empty/empty-description.svelte new file mode 100644 index 0000000..85a866c --- /dev/null +++ b/src/lib/components/ui/empty/empty-description.svelte @@ -0,0 +1,23 @@ + + +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/src/lib/components/ui/empty/empty-header.svelte b/src/lib/components/ui/empty/empty-header.svelte new file mode 100644 index 0000000..296eaf8 --- /dev/null +++ b/src/lib/components/ui/empty/empty-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/empty/empty-media.svelte b/src/lib/components/ui/empty/empty-media.svelte new file mode 100644 index 0000000..0b4e45d --- /dev/null +++ b/src/lib/components/ui/empty/empty-media.svelte @@ -0,0 +1,41 @@ + + + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/empty/empty-title.svelte b/src/lib/components/ui/empty/empty-title.svelte new file mode 100644 index 0000000..8c237aa --- /dev/null +++ b/src/lib/components/ui/empty/empty-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/empty/empty.svelte b/src/lib/components/ui/empty/empty.svelte new file mode 100644 index 0000000..4ccf060 --- /dev/null +++ b/src/lib/components/ui/empty/empty.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/empty/index.ts b/src/lib/components/ui/empty/index.ts new file mode 100644 index 0000000..ae4c106 --- /dev/null +++ b/src/lib/components/ui/empty/index.ts @@ -0,0 +1,22 @@ +import Root from "./empty.svelte"; +import Header from "./empty-header.svelte"; +import Media from "./empty-media.svelte"; +import Title from "./empty-title.svelte"; +import Description from "./empty-description.svelte"; +import Content from "./empty-content.svelte"; + +export { + Root, + Header, + Media, + Title, + Description, + Content, + // + Root as Empty, + Header as EmptyHeader, + Media as EmptyMedia, + Title as EmptyTitle, + Description as EmptyDescription, + Content as EmptyContent, +}; diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..ff1a4c8 --- /dev/null +++ b/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d71afbc --- /dev/null +++ b/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/select/index.ts b/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..4dec358 --- /dev/null +++ b/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from "./select.svelte"; +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Portal from "./select-portal.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/src/lib/components/ui/select/select-content.svelte b/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..4b9ca43 --- /dev/null +++ b/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/src/lib/components/ui/select/select-group-heading.svelte b/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/select/select-group.svelte b/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..a1f43bf --- /dev/null +++ b/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/select/select-item.svelte b/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..b85eef6 --- /dev/null +++ b/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/src/lib/components/ui/select/select-label.svelte b/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/select/select-portal.svelte b/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/select/select-scroll-down-button.svelte b/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/src/lib/components/ui/select/select-scroll-up-button.svelte b/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/src/lib/components/ui/select/select-separator.svelte b/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/ui/select/select-trigger.svelte b/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..dbb81df --- /dev/null +++ b/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/src/lib/components/ui/select/select.svelte b/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..f40999f --- /dev/null +++ b/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/sheet/index.ts b/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..28d7da1 --- /dev/null +++ b/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,34 @@ +import Root from "./sheet.svelte"; +import Portal from "./sheet-portal.svelte"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/src/lib/components/ui/sheet/sheet-close.svelte b/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..065fe04 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,60 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/src/lib/components/ui/sheet/sheet-description.svelte b/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-footer.svelte b/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-portal.svelte b/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 0000000..f3085a3 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-trigger.svelte b/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet.svelte b/src/lib/components/ui/sheet/sheet.svelte new file mode 100644 index 0000000..5bf9783 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/sidebar/constants.ts b/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..4de4435 --- /dev/null +++ b/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/src/lib/components/ui/sidebar/context.svelte.ts b/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/src/lib/components/ui/sidebar/sidebar-content.svelte b/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..f121800 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-footer.svelte b/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..a76dfe1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..b2e72b6 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group.svelte b/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-header.svelte b/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-input.svelte b/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..7d6d459 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..d3fe295 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..e8ecdb4 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..0acd1ec --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,103 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..68604e2 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..c8cd4ff --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..76bd1d9 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu.svelte b/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-provider.svelte b/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..5b0d0aa --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/src/lib/components/ui/sidebar/sidebar-rail.svelte b/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..704d54f --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-separator.svelte b/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..1825182 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..bac55d8 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/src/lib/components/ui/table/index.ts b/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/src/lib/components/ui/table/table-body.svelte b/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-caption.svelte b/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-cell.svelte b/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..2c0c26a --- /dev/null +++ b/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-footer.svelte b/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-head.svelte b/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..b67a6f9 --- /dev/null +++ b/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-header.svelte b/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/table/table-row.svelte b/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..0df769e --- /dev/null +++ b/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/src/lib/components/ui/table/table.svelte b/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
    + + {@render children?.()} +
    +
    diff --git a/src/lib/components/ui/textarea/index.ts b/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..124e9d0 --- /dev/null +++ b/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..1718604 --- /dev/null +++ b/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from "./tooltip.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Portal from "./tooltip-portal.svelte"; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..2662522 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/src/lib/components/ui/tooltip/tooltip-portal.svelte b/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..d234f7d --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-provider.svelte b/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..8150bef --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip.svelte b/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..0b0f9ce --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..4829c00 --- /dev/null +++ b/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..df3e117 --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,74 @@ +import { + mysqlTable, + int, + varchar, + text, + json, + mysqlEnum, + timestamp, + datetime, + boolean, + customType +} from 'drizzle-orm/mysql-core'; + +const longblob = customType<{ data: Buffer }>({ + dataType() { + return 'longblob'; + } +}); + +export const bugReports = mysqlTable('bug_reports', { + id: int('id').autoincrement().primaryKey(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull(), + description: text('description').notNull(), + hwid: varchar('hwid', { length: 255 }).notNull().default(''), + hostname: varchar('hostname', { length: 255 }).notNull().default(''), + os_user: varchar('os_user', { length: 255 }).notNull().default(''), + submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''), + system_info: json('system_info'), + status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'), + created_at: timestamp('created_at').notNull().defaultNow(), + updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const bugReportFiles = mysqlTable('bug_report_files', { + id: int('id').autoincrement().primaryKey(), + report_id: int('report_id') + .notNull() + .references(() => bugReports.id, { onDelete: 'cascade' }), + file_role: mysqlEnum('file_role', [ + 'screenshot', + 'mail_file', + 'localstorage', + 'config', + 'system_info' + ]).notNull(), + filename: varchar('filename', { length: 255 }).notNull(), + mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'), + file_size: int('file_size').notNull().default(0), + data: longblob('data').notNull(), + created_at: timestamp('created_at').notNull().defaultNow() +}); + +export const userTable = mysqlTable('user', { + id: varchar('id', { length: 255 }).primaryKey(), + username: varchar('username', { length: 255 }).notNull().unique(), + displayname: varchar('displayname', { length: 255 }).notNull().default(''), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'), + enabled: boolean('enabled').notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const sessionTable = mysqlTable('session', { + id: varchar('id', { length: 255 }).primaryKey(), + userId: varchar('user_id', { length: 255 }) + .notNull() + .references(() => userTable.id), + expiresAt: datetime('expires_at').notNull() +}); + +export type BugReport = typeof bugReports.$inferSelect; +export type BugReportFile = typeof bugReportFiles.$inferSelect; +export type BugReportStatus = BugReport['status']; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..cf3b188 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,38 @@ +import { Lucia } from 'lucia'; +import { DrizzleMySQLAdapter } from '@lucia-auth/adapter-drizzle'; +import { db } from './db'; +import { sessionTable, userTable } from '$lib/schema'; +import { dev } from '$app/environment'; + +const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !dev + } + }, + getUserAttributes: (attributes) => { + return { + username: attributes.username, + role: attributes.role, + displayname: attributes.displayname, + enabled: attributes.enabled + }; + } +}); + +declare module 'lucia' { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; + role: 'admin' | 'user'; + displayname: string; + enabled: boolean; +} +// End of file diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..d93f0ea --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import * as schema from '$lib/schema'; +import { env } from '$env/dynamic/private'; + +const pool = mysql.createPool({ + host: env.MYSQL_HOST || 'localhost', + port: Number(env.MYSQL_PORT) || 3306, + user: env.MYSQL_USER || 'emly', + password: env.MYSQL_PASSWORD, + database: env.MYSQL_DATABASE || 'emly_bugreports', + connectionLimit: 10, + idleTimeout: 60000 +}); + +export const db = drizzle(pool, { schema, mode: 'default' }); diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts new file mode 100644 index 0000000..d2769d1 --- /dev/null +++ b/src/lib/server/logger.ts @@ -0,0 +1,42 @@ +import { mkdirSync, appendFileSync, existsSync } from "fs"; +import { join } from "path"; + +let logFilePath: string | null = null; + +/** + * Initialize the logger. Creates the logs/ directory if needed + * and opens the log file in append mode. + */ +export function initLogger(filename = "dashboard.log"): void { + const logsDir = join(process.cwd(), "logs"); + if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true }); + } + logFilePath = join(logsDir, filename); + Log("LOGGER", "Logger initialized. Writing to:", logFilePath); +} + +/** + * Log a timestamped, source-tagged message to stdout and the log file. + * Format: [YYYY-MM-DD] - [HH:MM:SS] - [source] - message + */ +export function Log(source: string, ...args: unknown[]): void { + const now = new Date(); + const date = now.toISOString().slice(0, 10); + const time = now.toTimeString().slice(0, 8); + const msg = args + .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))) + .join(" "); + + const line = `[${date}] - [${time}] - [${source}] - ${msg}`; + + console.log(line); + + if (logFilePath) { + try { + appendFileSync(logFilePath, line + "\n"); + } catch { + // If file write fails, stdout logging still works + } + } +} diff --git a/src/lib/stores/presence.svelte.ts b/src/lib/stores/presence.svelte.ts new file mode 100644 index 0000000..87250fa --- /dev/null +++ b/src/lib/stores/presence.svelte.ts @@ -0,0 +1,95 @@ +import { browser } from '$app/environment'; +import { page } from '$app/stores'; + +export interface ActiveUser { + userId: string; + username: string; + displayname: string; + currentPath: string; + reportId: number | null; + lastSeen: number; +} + +class PresenceStore { + activeUsers = $state([]); + connected = $state(false); + + private eventSource: EventSource | null = null; + private heartbeatInterval: ReturnType | null = null; + private currentPath = '/'; + private unsubscribePage: (() => void) | null = null; + + connect() { + if (!browser || this.eventSource) return; + + this.eventSource = new EventSource('/api/presence'); + + this.eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.activeUsers = data; + } catch { + // ignore parse errors + } + }; + + this.eventSource.onopen = () => { + this.connected = true; + }; + + this.eventSource.onerror = () => { + this.connected = false; + // EventSource auto-reconnects + }; + + // Track current page and send heartbeats + this.unsubscribePage = page.subscribe((p) => { + this.currentPath = p.url.pathname; + }); + + // Send heartbeat every 15 seconds + this.sendHeartbeat(); + this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), 15000); + } + + disconnect() { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + if (this.unsubscribePage) { + this.unsubscribePage(); + this.unsubscribePage = null; + } + this.connected = false; + this.activeUsers = []; + } + + private async sendHeartbeat() { + try { + const reportMatch = this.currentPath.match(/^\/reports\/(\d+)/); + const reportId = reportMatch ? Number(reportMatch[1]) : null; + + await fetch('/api/presence/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + currentPath: this.currentPath, + reportId + }) + }); + } catch { + // ignore heartbeat failures + } + } + + getViewersForReport(reportId: number): ActiveUser[] { + return this.activeUsers.filter((u) => u.reportId === reportId); + } +} + +export const presence = new PresenceStore(); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..3cebf9a --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,46 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function formatDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +export const statusColors: Record = { + new: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + resolved: 'bg-green-500/20 text-green-400 border-green-500/30', + closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30' +}; + +export const statusLabels: Record = { + new: 'New', + in_review: 'In Review', + resolved: 'Resolved', + closed: 'Closed' +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..024d2f9 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,14 @@ + + +
    +

    {$page.status}

    +

    {$page.error?.message || 'Something went wrong'}

    + + Back to Reports + +
    diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..d95b760 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,25 @@ +import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { bugReports } from '$lib/schema'; +import { eq, count } from 'drizzle-orm'; + +export const load: LayoutServerLoad = async ({ locals, url }) => { + if (url.pathname === '/login') { + return { newCount: 0, user: null }; + } + + if (!locals.user) { + redirect(302, '/login'); + } + + const [result] = await db + .select({ count: count() }) + .from(bugReports) + .where(eq(bugReports.status, 'new')); + + return { + newCount: result.count, + user: locals.user + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..7c0c80c --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,169 @@ + + +{#if !data.user} + {@render children()} +{:else} + + + +
    + + EMLy Dashboard +
    +
    + + + Menu + + + + + {#snippet child({ props })} + + + Reports + + {/snippet} + + + {#if data.user.role === 'admin'} + + + {#snippet child({ props })} + + + Users + + {/snippet} + + + {/if} + + + + + + + + + {#snippet child({ props })} +
    +
    + {data.user.displayname || data.user.username} + + {data.user.role} + +
    +
    + +
    +
    + {/snippet} +
    +
    +
    +
    +
    + +
    +
    + + +

    + {#if $page.url.pathname === '/'} + Bug Reports + {:else if $page.url.pathname.startsWith('/reports/')} + Report Detail + {:else if $page.url.pathname === '/users'} + User Management + {:else} + Dashboard + {/if} +

    +
    +
    + {#if otherActiveUsers.length > 0} +
    + {#each otherActiveUsers.slice(0, 5) as activeUser} + + +
    + {(activeUser.displayname || activeUser.username).charAt(0).toUpperCase()} + +
    +
    + +

    {activeUser.displayname || activeUser.username}

    +

    + {#if activeUser.reportId} + Viewing Report #{activeUser.reportId} + {:else if activeUser.currentPath === '/users'} + User Management + {:else if activeUser.currentPath === '/'} + Reports List + {:else} + {activeUser.currentPath} + {/if} +

    +
    +
    + {/each} + {#if otherActiveUsers.length > 5} + +{otherActiveUsers.length - 5} + {/if} +
    + {/if} + {#if data.newCount > 0} +
    + + {data.newCount} new {data.newCount === 1 ? 'report' : 'reports'} +
    + {/if} +
    +
    +
    + {@render children()} +
    +
    +
    +{/if} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..2f4c72f --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,70 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq, like, or, count, sql, desc, and } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ url }) => { + const page = Math.max(1, Number(url.searchParams.get('page')) || 1); + const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20)); + const status = url.searchParams.get('status') || ''; + const search = url.searchParams.get('search') || ''; + + const conditions = []; + + if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) { + conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed')); + } + + if (search) { + conditions.push( + or( + like(bugReports.hostname, `%${search}%`), + like(bugReports.os_user, `%${search}%`), + like(bugReports.name, `%${search}%`), + like(bugReports.email, `%${search}%`) + ) + ); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + // Get total count + const [{ total }] = await db + .select({ total: count() }) + .from(bugReports) + .where(where); + + // Get paginated reports with file count + const reports = await db + .select({ + id: bugReports.id, + name: bugReports.name, + email: bugReports.email, + hostname: bugReports.hostname, + os_user: bugReports.os_user, + status: bugReports.status, + created_at: bugReports.created_at, + file_count: count(bugReportFiles.id) + }) + .from(bugReports) + .leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id)) + .where(where) + .groupBy(bugReports.id) + .orderBy(desc(bugReports.created_at)) + .limit(pageSize) + .offset((page - 1) * pageSize); + + return { + reports, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize) + }, + filters: { + status, + search + } + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..ab285dd --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,226 @@ + + +
    + +
    +
    + + e.key === 'Enter' && applyFilters()} + class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> +
    + applyFilters()}> + + {statusOptions.find((o) => o.value === statusFilter)?.label || 'All statuses'} + + + + {#each statusOptions as option} + + {/each} + + + + + {#if data.filters.search || data.filters.status} + + {/if} +
    + + + {#if data.reports.length === 0} +
    + + + + + + No reports found + + There are no bug reports matching your current filters. + + + {#if data.filters.search || data.filters.status} + + + + {:else} + + + + {/if} + +
    + {:else} +
    + + + + ID + Hostname + User + Reporter + Status + Files + Created + + + + {#each data.reports as report (report.id)} + goto(`/reports/${report.id}`)} + > + #{report.id} + {report.hostname || '—'} + {report.os_user || '—'} + +
    {report.name}
    +
    {report.email}
    +
    + + + {statusLabels[report.status]} + + + + {#if report.file_count > 0} + + + {report.file_count} + + {:else} + + {/if} + + + {formatDate(report.created_at)} + +
    + {/each} +
    +
    +
    + {/if} + + + {#if data.pagination.totalPages > 1} +
    +

    + Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min( + data.pagination.page * data.pagination.pageSize, + data.pagination.total + )} of {data.pagination.total} reports +

    +
    + + {#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p} + {#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)} + + {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2} + ... + {/if} + {/each} + +
    +
    + {/if} +
    + diff --git a/src/routes/api/presence/+server.ts b/src/routes/api/presence/+server.ts new file mode 100644 index 0000000..ff88606 --- /dev/null +++ b/src/routes/api/presence/+server.ts @@ -0,0 +1,49 @@ +import type { RequestHandler } from './$types'; +import { presenceMap, sseClients, broadcastPresence } from './state'; + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const userId = locals.user.id; + + const stream = new ReadableStream({ + start(controller) { + const clientId = `${userId}-${Date.now()}`; + + sseClients.set(clientId, controller); + + // Send current state immediately + const users = Array.from(presenceMap.values()).filter( + (u) => Date.now() - u.lastSeen < 60000 + ); + controller.enqueue(`data: ${JSON.stringify(users)}\n\n`); + + // Cleanup on close + const cleanup = () => { + sseClients.delete(clientId); + presenceMap.delete(userId); + broadcastPresence(); + }; + + // Use a heartbeat to detect disconnection + const keepAlive = setInterval(() => { + try { + controller.enqueue(': keepalive\n\n'); + } catch { + cleanup(); + clearInterval(keepAlive); + } + }, 30000); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }); +}; diff --git a/src/routes/api/presence/heartbeat/+server.ts b/src/routes/api/presence/heartbeat/+server.ts new file mode 100644 index 0000000..6b12a07 --- /dev/null +++ b/src/routes/api/presence/heartbeat/+server.ts @@ -0,0 +1,25 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { presenceMap, broadcastPresence } from '../state'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) { + error(401, 'Unauthorized'); + } + + const body = await request.json(); + const { currentPath, reportId } = body; + + presenceMap.set(locals.user.id, { + userId: locals.user.id, + username: locals.user.username, + displayname: locals.user.displayname, + currentPath: currentPath || '/', + reportId: reportId || null, + lastSeen: Date.now() + }); + + broadcastPresence(); + + return json({ ok: true }); +}; diff --git a/src/routes/api/presence/state.ts b/src/routes/api/presence/state.ts new file mode 100644 index 0000000..5b274bb --- /dev/null +++ b/src/routes/api/presence/state.ts @@ -0,0 +1,20 @@ +import type { ActiveUser } from '$lib/stores/presence.svelte'; + +// In-memory presence tracking - shared between SSE and heartbeat endpoints +export const presenceMap = new Map(); + +// SSE client connections +export const sseClients = new Map(); + +export function broadcastPresence() { + const users = Array.from(presenceMap.values()).filter((u) => Date.now() - u.lastSeen < 60000); + const data = `data: ${JSON.stringify(users)}\n\n`; + + for (const [clientId, controller] of sseClients.entries()) { + try { + controller.enqueue(data); + } catch { + sseClients.delete(clientId); + } + } +} diff --git a/src/routes/api/reports/[id]/download/+server.ts b/src/routes/api/reports/[id]/download/+server.ts new file mode 100644 index 0000000..51595c2 --- /dev/null +++ b/src/routes/api/reports/[id]/download/+server.ts @@ -0,0 +1,68 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq } from 'drizzle-orm'; +import JSZip from 'jszip'; + +export const GET: RequestHandler = async ({ params }) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const [report] = await db + .select() + .from(bugReports) + .where(eq(bugReports.id, id)) + .limit(1); + + if (!report) throw error(404, 'Report not found'); + + const files = await db + .select() + .from(bugReportFiles) + .where(eq(bugReportFiles.report_id, id)); + + const zip = new JSZip(); + + // Add report metadata as text file + const reportText = [ + `Bug Report #${report.id}`, + `========================`, + ``, + `Name: ${report.name}`, + `Email: ${report.email}`, + `Hostname: ${report.hostname}`, + `OS User: ${report.os_user}`, + `HWID: ${report.hwid}`, + `IP: ${report.submitter_ip}`, + `Status: ${report.status}`, + `Created: ${report.created_at.toISOString()}`, + `Updated: ${report.updated_at.toISOString()}`, + ``, + `Description:`, + `------------`, + report.description, + ``, + ...(report.system_info + ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)] + : []) + ].join('\n'); + + zip.file('report.txt', reportText); + + // Add all files + for (const file of files) { + const folder = file.file_role; + zip.file(`${folder}/${file.filename}`, file.data); + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }); + + return new Response(new Uint8Array(zipBuffer), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="report-${id}.zip"`, + 'Content-Length': String(zipBuffer.length) + } + }); +}; diff --git a/src/routes/api/reports/[id]/files/[fileId]/+server.ts b/src/routes/api/reports/[id]/files/[fileId]/+server.ts new file mode 100644 index 0000000..980bdfd --- /dev/null +++ b/src/routes/api/reports/[id]/files/[fileId]/+server.ts @@ -0,0 +1,28 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { bugReportFiles } from '$lib/schema'; +import { eq, and } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ params }) => { + const reportId = Number(params.id); + const fileId = Number(params.fileId); + + if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID'); + + const [file] = await db + .select() + .from(bugReportFiles) + .where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId))) + .limit(1); + + if (!file) throw error(404, 'File not found'); + + return new Response(new Uint8Array(file.data), { + headers: { + 'Content-Type': file.mime_type, + 'Content-Disposition': `inline; filename="${file.filename}"`, + 'Content-Length': String(file.file_size) + } + }); +}; diff --git a/src/routes/api/reports/refresh/+server.ts b/src/routes/api/reports/refresh/+server.ts new file mode 100644 index 0000000..5506987 --- /dev/null +++ b/src/routes/api/reports/refresh/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { bugReports } from '$lib/schema'; +import { count } from 'drizzle-orm'; + +export const GET: RequestHandler = async () => { + const [{ total }] = await db + .select({ total: count() }) + .from(bugReports); + + return json({ total }); +}; \ No newline at end of file diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..c1ca5af --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,63 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { verify } from '@node-rs/argon2'; +import { lucia } from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import { userTable } from '$lib/schema'; +import { eq } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + redirect(302, '/'); + } +}; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const formData = await request.formData(); + const username = formData.get('username'); + const password = formData.get('password'); + + if (typeof username !== 'string' || typeof password !== 'string') { + return fail(400, { message: 'Invalid input' }); + } + + if (!username || !password) { + return fail(400, { message: 'Username and password are required' }); + } + + const [user] = await db + .select() + .from(userTable) + .where(eq(userTable.username, username)) + .limit(1); + + if (!user) { + return fail(400, { message: 'Invalid username or password' }); + } + + const validPassword = await verify(user.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + + if (!validPassword) { + return fail(400, { message: 'Invalid username or password' }); + } + + if (!user.enabled) { + return fail(403, { message: 'Account is disabled' }); + } + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + redirect(302, '/'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..2c74fee --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,61 @@ + + +
    +
    +
    + +

    EMLy Dashboard

    +

    Sign in to continue

    +
    + + {#if form?.message} +
    + {form.message} +
    + {/if} + +
    +
    + + +
    + +
    + + +
    + + +
    +
    +
    diff --git a/src/routes/logout/+page.server.ts b/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..67b748e --- /dev/null +++ b/src/routes/logout/+page.server.ts @@ -0,0 +1,24 @@ +import type { Actions, PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { lucia } from '$lib/server/auth'; + +export const load: PageServerLoad = async () => { + redirect(302, '/'); +}; + +export const actions: Actions = { + default: async ({ locals, cookies }) => { + if (!locals.session) { + redirect(302, '/login'); + } + + await lucia.invalidateSession(locals.session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + redirect(302, '/login'); + } +}; diff --git a/src/routes/reports/[id]/+page.server.ts b/src/routes/reports/[id]/+page.server.ts new file mode 100644 index 0000000..50cfe11 --- /dev/null +++ b/src/routes/reports/[id]/+page.server.ts @@ -0,0 +1,45 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { bugReports, bugReportFiles } from '$lib/schema'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const id = Number(params.id); + if (isNaN(id)) throw error(400, 'Invalid report ID'); + + const [report] = await db + .select() + .from(bugReports) + .where(eq(bugReports.id, id)) + .limit(1); + + if (!report) throw error(404, 'Report not found'); + + const files = await db + .select({ + id: bugReportFiles.id, + report_id: bugReportFiles.report_id, + file_role: bugReportFiles.file_role, + filename: bugReportFiles.filename, + mime_type: bugReportFiles.mime_type, + file_size: bugReportFiles.file_size, + created_at: bugReportFiles.created_at + }) + .from(bugReportFiles) + .where(eq(bugReportFiles.report_id, id)); + + return { + report: { + ...report, + system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null, + created_at: report.created_at.toISOString(), + updated_at: report.updated_at.toISOString() + }, + files: files.map((f) => ({ + ...f, + created_at: f.created_at.toISOString() + })), + currentUserId: locals.user?.id ?? '' + }; +}; diff --git a/src/routes/reports/[id]/+page.svelte b/src/routes/reports/[id]/+page.svelte new file mode 100644 index 0000000..25a4e2e --- /dev/null +++ b/src/routes/reports/[id]/+page.svelte @@ -0,0 +1,304 @@ + + +
    + + + + + + +
    +
    +
    + Report #{data.report.id} + + {statusLabels[data.report.status]} + +
    + + Submitted by {data.report.name} ({data.report.email}) + +
    +
    + {#if otherViewers.length > 0} +
    + + {#each otherViewers as viewer} + + +
    + {(viewer.displayname || viewer.username).charAt(0).toUpperCase()} + +
    +
    + + {viewer.displayname || viewer.username} is viewing this report + +
    + {/each} +
    + {/if} + + updateStatus(val)} + > + + {statusLabels[data.report.status]} + + + + + + + + + + + + + + +
    +
    +
    + + +
    +
    +

    Hostname

    +

    {data.report.hostname || '—'}

    +
    +
    +

    OS User

    +

    {data.report.os_user || '—'}

    +
    +
    +

    HWID

    +

    {data.report.hwid || '—'}

    +
    +
    +

    IP Address

    +

    {data.report.submitter_ip || '—'}

    +
    +
    +

    Created

    +

    {formatDate(data.report.created_at)}

    +
    +
    +

    Updated

    +

    {formatDate(data.report.updated_at)}

    +
    +
    +
    +
    + + + + + Description + + +

    {data.report.description}

    +
    +
    + + + {#if data.report.system_info} + + + System Information + + +
    +