initial commit

This commit is contained in:
2026-06-22 17:47:16 +02:00
commit 8a81eb2634
491 changed files with 26185 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Drizzle
DATABASE_URL=file:local.db
+28
View File
@@ -0,0 +1,28 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide
project.inlang/cache/
# SQLite
*.db
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+10
View File
@@ -0,0 +1,10 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
/drizzle/
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"svelte.svelte-vscode",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"files.associations": {
"*.css": "tailwindcss"
},
"eslint.format.enable": true
}
+119
View File
@@ -0,0 +1,119 @@
# Nadir Web UI
SvelteKit dashboard for [nadir-agent](https://tea.urania.dev/urania/nadir-agent) -
a central web console that talks to one or many Nadir backend nodes over their
typed REST API.
The agent does the system-administration work (systemd services, users,
packages, networking, audit, terminal, ...). This UI is the operator's view of
it: sign in, register machines with their bearer token, see live host metrics
on the dashboard, and drive everyday tasks from the browser.
---
## Stack
- **SvelteKit** (Svelte 5, adapter-node) + **TailwindCSS 4** + **shadcn-svelte**
- **Bun** as the runtime / package manager / dev server
- **Drizzle ORM** on **SQLite** (libSQL driver) for the UI's own state (users,
machines, encrypted tokens)
- **Better Auth** with email/password, OAuth, optional 2FA, admin & username
plugins
- **Paraglide** for i18n (messages in `messages/`)
- **openapi-fetch** + typed client generated from the nadir-agent OpenAPI spec
(`src/lib/server/nadir-agent/schema.d.ts`)
---
## Getting started
Prerequisites: [Bun](https://bun.com) and a reachable nadir-agent instance with
a machine token (see the agent README's *Connecting a dashboard* section).
```sh
bun install
cp .env.example .env # then edit (see below)
bun run db:push # creates db.sqlite from the Drizzle schema
bun run dev # starts on http://localhost:5173
```
### Environment
Set in `.env` (validated at startup via `src/lib/const/schema.ts`):
| Var | Default | Purpose |
| --------------------------- | ----------------------- | ------------------------------------------------------ |
| `CRYPTO_SECRET` | (required) | Encrypts machine bearer tokens at rest in the local DB |
| `DATABASE_URL` | `file:db.sqlite` | libSQL connection string |
| `ORIGIN` | `http://localhost:5173` | Public origin (used by Better Auth) |
| `DISABLE_SIGNUP` | `false` | Lock down registration |
| `ENABLE_2FA` | `false` | Enable the TOTP 2FA flow |
| `ENABLE_EMAIL_AND_PASSWORD` | `true` | Toggle email/password auth |
| `SMTP_*` | - | Outbound mail for verification / reset / 2FA |
OAuth providers (optional) live in `config/oauth.json` and are passed straight
to Better Auth's `genericOAuth` plugin.
---
## Scripts
```sh
bun run dev # vite dev server
bun run build # production build (adapter-node -> build/)
bun run preview # preview the production build
bun run check # svelte-check
bun run lint # prettier + eslint
bun run format # prettier --write
bun run db:push # apply schema to the DB
bun run db:generate # generate migration from schema changes
bun run db:migrate # run pending migrations
bun run db:studio # drizzle-kit studio
```
---
## Project layout
```
src/
routes/
auth/ sign-in, sign-up, forgot/reset password, 2fa setup
dashboard/ machine list and per-machine live dashboard
system/ date/time, localization
admin/ users, config
api/ internal endpoints (e.g. emailer)
lib/
auth/ Better Auth server + client
components/ shadcn-svelte UI + dashboard panels (cpu, network, storage, ...)
machines/ valibot schemas
remotes/ SvelteKit remote functions (server.remote.ts, machines.remote.ts, ...)
server/
db/ Drizzle schema + custom encrypted column type
emails/ nodemailer + better-svelte-email templates
nadir-agent/ generated OpenAPI types + typed client
paraglide/ generated i18n runtime
messages/ translation source (en, ...)
config/oauth.json optional OAuth providers passed to Better Auth
```
---
## Deploying
`adapter-node` produces a plain Node/Bun server under `build/`:
```sh
bun run build
PORT=3000 ORIGIN=https://nadir.example.com bun run build/index.js
```
Put it behind the same reverse proxy you use for nadir-agent, or co-host them.
The agent's CSRF rules apply when the UI calls it cross-origin - see the agent
README's *Connecting a dashboard* section.
---
## License
MIT.
+1376
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.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",
"style": "nova",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
}
+1
View File
@@ -0,0 +1 @@
[]
BIN
View File
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
dbCredentials: { url: process.env.DATABASE_URL },
dialect: 'sqlite',
schema: './src/lib/server/db/schema.ts',
strict: true,
verbose: true
});
@@ -0,0 +1,70 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT `fk_account_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`expires_at` integer NOT NULL,
`token` text NOT NULL UNIQUE,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
`impersonated_by` text,
CONSTRAINT `fk_session_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `two_factor` (
`id` text PRIMARY KEY,
`secret` text NOT NULL,
`backup_codes` text NOT NULL,
`user_id` text NOT NULL,
`verified` integer DEFAULT true,
CONSTRAINT `fk_two_factor_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY,
`name` text NOT NULL,
`email` text NOT NULL UNIQUE,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`role` text,
`banned` integer DEFAULT false,
`ban_reason` text,
`ban_expires` integer,
`two_factor_enabled` integer DEFAULT false,
`username` text UNIQUE,
`display_username` text
);
--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE INDEX `twoFactor_secret_idx` ON `two_factor` (`secret`);--> statement-breakpoint
CREATE INDEX `twoFactor_userId_idx` ON `two_factor` (`user_id`);--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
@@ -0,0 +1,688 @@
{
"version": "7",
"dialect": "sqlite",
"id": "422c3fd2-6456-4770-8ba8-0707c5a220b8",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
"name": "account",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "two_factor",
"entityType": "tables"
},
{
"name": "user",
"entityType": "tables"
},
{
"name": "verification",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "account_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "scope",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "password",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ip_address",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_agent",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "impersonated_by",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "backup_codes",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "verified",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "email_verified",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "role",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "banned",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_reason",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_expires",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "two_factor_enabled",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "username",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "display_username",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "identifier",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "verification"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_account_user_id_user_id_fk",
"entityType": "fks",
"table": "account"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_user_id_user_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_two_factor_user_id_user_id_fk",
"entityType": "fks",
"table": "two_factor"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "two_factor_pk",
"table": "two_factor",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "user_pk",
"table": "user",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "verification_pk",
"table": "verification",
"entityType": "pks"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "account_userId_idx",
"entityType": "indexes",
"table": "account"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_userId_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "secret",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_secret_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_userId_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "identifier",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "verification_identifier_idx",
"entityType": "indexes",
"table": "verification"
},
{
"columns": [
"token"
],
"nameExplicit": false,
"name": "session_token_unique",
"entityType": "uniques",
"table": "session"
},
{
"columns": [
"email"
],
"nameExplicit": false,
"name": "user_email_unique",
"entityType": "uniques",
"table": "user"
},
{
"columns": [
"username"
],
"nameExplicit": false,
"name": "user_username_unique",
"entityType": "uniques",
"table": "user"
}
],
"renames": []
}
@@ -0,0 +1,8 @@
CREATE TABLE `machines` (
`host` text DEFAULT '127.0.0.1' NOT NULL,
`id` text PRIMARY KEY,
`name` text,
`order` integer UNIQUE,
`port` integer DEFAULT 9999 NOT NULL,
`token` text NOT NULL
);
@@ -0,0 +1,770 @@
{
"version": "7",
"dialect": "sqlite",
"id": "32f84774-44a1-4363-85d8-8b00b9de063f",
"prevIds": [
"422c3fd2-6456-4770-8ba8-0707c5a220b8"
],
"ddl": [
{
"name": "account",
"entityType": "tables"
},
{
"name": "machines",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "two_factor",
"entityType": "tables"
},
{
"name": "user",
"entityType": "tables"
},
{
"name": "verification",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "account_id",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "password",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "scope",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'127.0.0.1'",
"generated": null,
"name": "host",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "machines"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "order",
"entityType": "columns",
"table": "machines"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "9999",
"generated": null,
"name": "port",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "machines"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "impersonated_by",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ip_address",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_agent",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "backup_codes",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "verified",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_expires",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "banned",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_reason",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "display_username",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "email_verified",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "role",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "two_factor_enabled",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "username",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "identifier",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "verification"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_account_user_id_user_id_fk",
"entityType": "fks",
"table": "account"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_user_id_user_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_two_factor_user_id_user_id_fk",
"entityType": "fks",
"table": "two_factor"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "machines_pk",
"table": "machines",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "two_factor_pk",
"table": "two_factor",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "user_pk",
"table": "user",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "verification_pk",
"table": "verification",
"entityType": "pks"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "account_userId_idx",
"entityType": "indexes",
"table": "account"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_userId_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "secret",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_secret_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_userId_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "identifier",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "verification_identifier_idx",
"entityType": "indexes",
"table": "verification"
},
{
"columns": [
"order"
],
"nameExplicit": false,
"name": "machines_order_unique",
"entityType": "uniques",
"table": "machines"
},
{
"columns": [
"token"
],
"nameExplicit": false,
"name": "session_token_unique",
"entityType": "uniques",
"table": "session"
},
{
"columns": [
"email"
],
"nameExplicit": false,
"name": "user_email_unique",
"entityType": "uniques",
"table": "user"
},
{
"columns": [
"username"
],
"nameExplicit": false,
"name": "user_username_unique",
"entityType": "uniques",
"table": "user"
}
],
"renames": []
}
@@ -0,0 +1,3 @@
ALTER TABLE `machines` ADD `address` text DEFAULT 'http://127.0.0.1:9999' NOT NULL;--> statement-breakpoint
ALTER TABLE `machines` DROP COLUMN `host`;--> statement-breakpoint
ALTER TABLE `machines` DROP COLUMN `port`;
@@ -0,0 +1,760 @@
{
"version": "7",
"dialect": "sqlite",
"id": "b3403d40-d4c3-4f1a-8d1d-763c548f5bc7",
"prevIds": [
"32f84774-44a1-4363-85d8-8b00b9de063f"
],
"ddl": [
{
"name": "account",
"entityType": "tables"
},
{
"name": "machines",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "two_factor",
"entityType": "tables"
},
{
"name": "user",
"entityType": "tables"
},
{
"name": "verification",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "account_id",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "password",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "scope",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'http://127.0.0.1:9999'",
"generated": null,
"name": "address",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "machines"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "order",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "machines"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "impersonated_by",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ip_address",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_agent",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "backup_codes",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "verified",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_expires",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "banned",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_reason",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "display_username",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "email_verified",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "role",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "two_factor_enabled",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "username",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "identifier",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "verification"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_account_user_id_user_id_fk",
"entityType": "fks",
"table": "account"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_user_id_user_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_two_factor_user_id_user_id_fk",
"entityType": "fks",
"table": "two_factor"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "machines_pk",
"table": "machines",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "two_factor_pk",
"table": "two_factor",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "user_pk",
"table": "user",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "verification_pk",
"table": "verification",
"entityType": "pks"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "account_userId_idx",
"entityType": "indexes",
"table": "account"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_userId_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "secret",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_secret_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_userId_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "identifier",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "verification_identifier_idx",
"entityType": "indexes",
"table": "verification"
},
{
"columns": [
"order"
],
"nameExplicit": false,
"name": "machines_order_unique",
"entityType": "uniques",
"table": "machines"
},
{
"columns": [
"token"
],
"nameExplicit": false,
"name": "session_token_unique",
"entityType": "uniques",
"table": "session"
},
{
"columns": [
"email"
],
"nameExplicit": false,
"name": "user_email_unique",
"entityType": "uniques",
"table": "user"
},
{
"columns": [
"username"
],
"nameExplicit": false,
"name": "user_username_unique",
"entityType": "uniques",
"table": "user"
}
],
"renames": []
}
+42
View File
@@ -0,0 +1,42 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import perfectionist from 'eslint-plugin-perfectionist';
import svelte from 'eslint-plugin-svelte';
import { defineConfig, includeIgnoreFile } from 'eslint/config';
import globals from 'globals';
import path from 'node:path';
import ts from 'typescript-eslint';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
perfectionist.configs['recommended-alphabetical'],
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
extraFileExtensions: ['.svelte'],
parser: ts.parser,
projectService: true
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);
+271
View File
@@ -0,0 +1,271 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"account": "Account",
"already_have_account": "Already have an account?",
"appname": "NadiЯ",
"back_to_login": "Back to login",
"backup_code": "Backup code",
"backup_codes_notice": "Store these somewhere safe. Each code works once if you lose your device.",
"backup_codes_title": "Save your backup codes",
"check_your_email": "Check your email",
"code": "Code",
"confirm_password": "Confirm password",
"continue_action": "Continue",
"create_account": "Create account",
"dashboard": "Dashboard",
"dashboard_architecture": "Architecture",
"dashboard_ascending": "Ascending",
"dashboard_clock": "Clock",
"dashboard_col_free": "Free",
"dashboard_col_ip": "IP",
"dashboard_col_method": "Method",
"dashboard_col_mount": "Mount",
"dashboard_col_name": "Name",
"dashboard_col_path": "Path",
"dashboard_col_sensor": "Sensor",
"dashboard_col_size": "Size",
"dashboard_col_status": "Status",
"dashboard_col_temp": "Temp",
"dashboard_col_time": "Time",
"dashboard_col_usage": "Usage",
"dashboard_col_user": "User",
"dashboard_cpu": "CPU",
"dashboard_cpu_detail": "{cores} cores · {current} ({min}{max})",
"dashboard_descending": "Descending",
"dashboard_dns": "DNS",
"dashboard_free_of": "{free} free of {total}",
"dashboard_hardware_clock": "Hardware clock",
"dashboard_heatmap_less": "Less",
"dashboard_heatmap_more": "More",
"dashboard_heatmap_samples": "{count} samples",
"dashboard_interval_10s": "Every 10s",
"dashboard_interval_30s": "Every 30s",
"dashboard_interval_5s": "Every 5s",
"dashboard_interval_custom": "Custom",
"dashboard_interval_second": "Every second",
"dashboard_kernel": "Kernel",
"dashboard_keymap": "Keymap",
"dashboard_language": "Language",
"dashboard_load_average": "Load Average",
"dashboard_load_detail": "{loadPct}% of {cores}c · 5m {load5} · 15m {load15}",
"dashboard_local_time": "Local time",
"dashboard_locale": "Locale",
"dashboard_logical_cores": "{cores} logical cores",
"dashboard_memory": "Memory",
"dashboard_nameserver": "Nameserver",
"dashboard_network": "Network",
"dashboard_next": "Next",
"dashboard_none": "none",
"dashboard_nothing_to_show": "Nothing to show.",
"dashboard_not_synced": "Not synced",
"dashboard_os": "OS",
"dashboard_packages": "Packages",
"dashboard_pagination_info": "{start}{end} of {total}",
"dashboard_prev": "Prev",
"dashboard_recent_activity": "Recent activity",
"dashboard_pause": "Pause auto-refresh",
"dashboard_refresh": "Refresh",
"dashboard_resume": "Resume auto-refresh",
"dashboard_search_placeholder": "Search",
"dashboard_seconds_abbreviation": "s",
"dashboard_since": "since {bootTime}",
"dashboard_status_down": "down",
"dashboard_status_up": "up",
"dashboard_storage": "Storage",
"dashboard_swap": "Swap {swapPct}%",
"dashboard_synchronized": "Synchronized",
"dashboard_syncing": "Syncing…",
"dashboard_system": "System",
"dashboard_temperatures": "Temperatures",
"dashboard_time": "Time",
"dashboard_timezone": "Timezone",
"dashboard_up_to_date": "up to date",
"dashboard_updates": "{count} updates",
"dashboard_uptime": "Uptime",
"dashboard_used_percent": "{used}% used",
"dashboard_utc": "UTC",
"download": "Download",
"email": "Email",
"email_complete_body": "Your account has been created. Choose a password to finish setting it up.",
"email_complete_button": "Set password",
"email_complete_heading": "Complete your registration",
"email_complete_subject": "Complete your registration",
"email_greeting": "Hi {name},",
"email_link_fallback": "If the button doesn't work, copy and paste this link into your browser:",
"email_otp_body": "Use this code to finish signing in:",
"email_otp_heading": "Your verification code",
"email_otp_ignore": "If you didn't try to sign in, you can safely ignore this email.",
"email_otp_subject": "Your verification code",
"email_placeholder": "admin@example.com",
"email_reset_body": "We received a request to reset the password for your account. Click the button below to choose a new password.",
"email_reset_button": "Reset password",
"email_reset_heading": "Reset your password",
"email_reset_ignore": "If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.",
"email_reset_subject": "Reset your password",
"email_verify_body": "Click the button below to verify your email address and activate your account.",
"email_verify_button": "Verify email",
"email_verify_heading": "Verify your email",
"email_verify_ignore": "If you didn't create an account, you can safely ignore this email.",
"email_verify_subject": "Verify your email address",
"enter_password_to_continue": "Confirm your password to continue",
"errors_email_invalid": "Enter a valid email address",
"errors_generic": "An error occurred during this operation, please review Nadir Logs for more information.",
"errors_invalid_code": "Invalid or expired code, try again.",
"errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999",
"errors_non_empty": "This field is required",
"errors_password_too_short": "Password must be at least {min} characters",
"errors_password_weak": "Use upper- and lower-case letters and at least one number.",
"errors_passwords_no_match": "Passwords do not match",
"errors_username_too_short": "Username must be at least {min} characters",
"errors_wrong_credentials": "Wrong credentials, try again.",
"errors_unauthenticated": "Unauthenticated",
"errors_not_found": "This item has not been found",
"finish": "Finish",
"forbidden": "You are not allowed to this operation.",
"forgot_password": "Forgot your password?",
"forgot_password_description": "Enter your email and we'll send you a reset link.",
"forgot_password_title": "Forgot your password?",
"home": "Home",
"invalid_reset_link": "This link is invalid or has expired.",
"login": "Login",
"login_social_description": "You have to login to use this platform. Use your favorite social or your credentials",
"login_with": "Login with <span class=capitalize>{social}</span>",
"logout": "Logout",
"manual_entry_key": "Can't scan? Enter this key in your authenticator app manually:",
"name": "Name",
"name_placeholder": "Jane Doe",
"nav_admin": "Admin",
"nav_admin_config": "Config",
"nav_admin_config_desc": "Application-wide configuration.",
"nav_admin_users": "Users",
"nav_admin_users_desc": "Manage user accounts, roles and access.",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"machine_actions": "Server actions",
"machine_add": "Add server",
"machine_add_description": "Connect a new server to manage from this dashboard.",
"machine_delete_confirm": "This permanently removes \"{name}\" from the database. The connected server is not touched.",
"machine_delete_title": "Delete server?",
"machine_edit": "Edit server",
"machine_edit_description": "Update the connection details for this server.",
"machine_save_edit": "Save changes",
"machine_token_keep": "Leave blank to keep the current token.",
"machine_address": "Address",
"machine_address_placeholder": "http://127.0.0.1:9999",
"machine_name": "Name",
"machine_name_placeholder": "Production server",
"machine_none": "No servers yet.",
"machine_save": "Add server",
"machine_search_placeholder": "Search servers…",
"machine_token": "Token",
"machine_token_placeholder": "Agent bearer token",
"nav_dashboard_overview": "Overview",
"nav_dashboard_overview_desc": "System status at a glance.",
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {pages}",
"pagination_previous": "Previous",
"nav_system": "System",
"nav_system_datetime": "Date & Time",
"nav_system_datetime_desc": "Clock, timezone and time synchronisation.",
"nav_system_localization": "Localization",
"nav_system_localization_desc": "Language, locale and region settings.",
"new_password": "New password",
"no_account": "No account yet?",
"or": "Or",
"password": "Password",
"password_hint": "At least 8 characters, mixing upper- and lower-case letters and a number.",
"privacy_policy": "Privacy Policy",
"remember_me": "Remember me",
"reset_link_sent": "If an account exists for that email, a reset link is on its way.",
"reset_password_action": "Update password",
"reset_password_description": "Choose a strong password you don't use anywhere else.",
"reset_password_title": "Set a new password",
"scan_qr": "Add this key to your authenticator app, then enter the generated code below.",
"send_reset_link": "Send reset link",
"setup_2fa_description": "Add an extra layer of security to your account.",
"setup_2fa_title": "Set up two-factor authentication",
"sign_up": "Sign up",
"sign_up_description": "Sign up with your email and a username.",
"sign_up_title": "Create your account",
"terms_notice": "By clicking continue, you agree to our <a class='link' href={terms}>Terms of Service</a> and <a class='link' href={privacy}>Privacy Policy</a>.",
"trust_device": "Trust this device for 30 days",
"two_factor_description": "Enter the 6-digit code from your authenticator app.",
"two_factor_title": "Two-factor authentication",
"use_authenticator": "Use authenticator app",
"use_backup_code": "Use a backup code",
"username": "Username",
"username_placeholder": "admin",
"verification_sent": "We sent a verification link to {email}. Click it to activate your account.",
"verify": "Verify",
"verify_your_email": "You need to first verify your email address",
"welcome_back": "Welcome back",
"users_title": "Users",
"users_description": "Manage application users.",
"users_add": "Add User",
"users_create": "Create",
"users_edit": "Edit",
"users_delete": "Delete",
"users_ban": "Ban",
"users_unban": "Unban",
"users_search_placeholder": "Search by email…",
"users_rows_per_page": "Rows per page",
"users_role": "Role",
"users_role_user": "User",
"users_role_admin": "Admin",
"users_created_at": "Joined",
"users_status": "Status",
"users_banned": "Banned",
"users_active": "Active",
"users_actions": "Actions",
"users_create_title": "Create user",
"users_create_description": "Add a new user to the system.",
"users_edit_title": "Edit user",
"users_edit_description": "Update user details.",
"users_delete_confirm_title": "Delete user?",
"users_delete_confirm_description": "This permanently removes the user. Optionally ban the email to prevent re-registration.",
"users_delete_ban_email": "Also ban this email",
"users_ban_reason": "Reason (optional)",
"users_no_results": "No users found.",
"users_page_of": "Page {page} of {total}",
"users_prev": "Previous",
"users_next": "Next",
"users_saved": "User saved",
"users_created": "User created",
"users_deleted": "User deleted",
"users_ban_action_title": "Ban user?",
"cancel": "Cancel",
"users_filter": "Filter",
"users_filter_title": "Filter Users",
"users_filter_active": "Active",
"users_filter_active_hint": "Users with a recent session.",
"users_filter_joined": "Joined",
"users_filter_any_time": "Any Time",
"users_filter_24h": "Last 24h",
"users_filter_7d": "Last 7 days",
"users_filter_30d": "Last 30 days",
"users_filter_date_range": "Date Range",
"users_filter_date_from": "From",
"users_filter_date_to": "To",
"users_filter_email_verified": "Email verified only",
"users_filter_online_only": "Online users only",
"users_filter_online_hint": "Users with an active session.",
"users_filter_show_banned": "Show banned users",
"users_filter_display": "Display",
"users_filter_reset": "Reset All",
"users_filter_count": "{n} filters active",
"users_invite": "Invite",
"users_invite_title": "Invite user",
"users_invite_description": "Send an email invitation. The user sets their own password.",
"users_invited": "Invitation sent",
"users_pending": "Pending",
"users_pending_expires": "Invite expires {date}",
"users_pending_no_invite": "Email not verified",
"users_resend_invite": "Resend invite",
"settings": "Settings",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System",
"language": "Language"
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "clean",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun --bun vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@better-svelte-email/cli": "^2.1.1",
"@eslint/js": "^10.0.1",
"@fontsource-variable/geist": "^5.2.9",
"@inlang/paraglide-js": "^2.18.2",
"@internationalized/date": "^3.12.0",
"@libsql/client": "^0.17.3",
"@lucide/svelte": "^1.21.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/table-core": "^8.21.3",
"@types/bun": "^1.3.14",
"@types/node": "^24",
"@types/nodemailer": "^8.0.1",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
"drizzle-kit": "^1.0.0-beta.22",
"drizzle-orm": "^1.0.0-beta.22",
"embla-carousel-svelte": "^8.6.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-perfectionist": "^5.9.1",
"eslint-plugin-svelte": "^3.19.0",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"layerchart": "2.0.0-next.48",
"mode-watcher": "^1.1.0",
"openapi-typescript": "^7.13.0",
"paneforge": "^1.0.2",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn-svelte": "^1.3.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"svelte-sonner": "^1.1.0",
"sveltekit-superforms": "^2.30.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^8.0.16"
},
"dependencies": {
"@better-auth/infra": "^0.2.14",
"@better-svelte-email/components": "^2.1.1",
"@better-svelte-email/server": "^2.1.1",
"better-auth": "^1.6.20",
"nodemailer": "^9.0.1",
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"uqr": "^0.1.3",
"valibot": "^1.4.1"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "en",
"locales": ["en"]
}
+23
View File
@@ -0,0 +1,23 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { auth } from '$lib/auth/server';
// for information about these interfaces
declare global {
type Auth = typeof auth.$Infer;
type Fetch = typeof fetch;
type Session = Auth['Session']['session'];
type User = Auth['Session']['user'];
namespace App {
// interface Error {}
interface Locals {
session: null | Session;
user: null | User;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="%paraglide.lang%" dir="%paraglide.dir%">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&family=Work+Sans:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { dev } from '$app/env';
import { building } from '$app/environment';
import { auth } from '$lib/auth/server';
import { env } from '$lib/const/schema';
import { getTextDirection } from '$lib/paraglide/runtime';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { svelteKitHandler } from 'better-auth/svelte-kit';
const handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({
headers: event.request.headers
});
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
} else if (
!event.url.pathname.startsWith('/auth') &&
!event.url.pathname.startsWith('/api/auth')
) {
redirect(307, '/auth/sign-in');
}
if (
env.ENABLE_2FA &&
session?.user !== undefined &&
'twoFactorEnabled' in session.user &&
session?.user.twoFactorEnabled !== true &&
!event.url.pathname.startsWith('/auth') &&
!event.url.pathname.startsWith('/api/auth')
)
redirect(307, '/auth/setup-2fa');
if (session?.user && 'twoFactorRedirect' in session.user) redirect(307, '/auth/2fa');
if (dev && env.ORIGIN.startsWith('https:')) event.url.protocol = 'https:';
if (event.url.pathname.startsWith('/admin')) {
const roles = (session?.user?.role ?? '')
.split(',')
.map((r) => r.trim())
.filter(Boolean);
if (!roles.includes('admin')) redirect(307, '/dashboard');
}
return svelteKitHandler({ auth, building, event, resolve });
};
const handleParaglide: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ locale, request }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) =>
html
.replace('%paraglide.lang%', locale)
.replace('%paraglide.dir%', getTextDirection(locale))
});
});
export const handle: Handle = sequence(handleBetterAuth, handleParaglide);
+5
View File
@@ -0,0 +1,5 @@
import type { Reroute } from '@sveltejs/kit';
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-orbit-icon lucide-orbit size-full!">
<path d="M20.341 6.484A10 10 0 0 1 10.266 21.85" />
<path d="M3.659 17.516A10 10 0 0 1 13.74 2.152" />
<circle cx="12" cy="12" r="3" />
<circle cx="19" cy="5" r="2" />
<circle cx="5" cy="19" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

+31
View File
@@ -0,0 +1,31 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { createAuthClient } from 'better-auth/client';
import {
adminClient,
genericOAuthClient,
inferAdditionalFields,
twoFactorClient,
usernameClient
} from 'better-auth/client/plugins';
export const getAuthClient = () =>
createAuthClient({
baseURL: env.PUBLIC_ORIGIN,
fetchOptions: {
customFetchImpl: page.data.fetch
},
plugins: [
adminClient(),
genericOAuthClient(),
twoFactorClient({
onTwoFactorRedirect: async () => {
await goto(resolve('/auth/2fa'));
}
}),
usernameClient(),
inferAdditionalFields<Auth>()
]
});
+93
View File
@@ -0,0 +1,93 @@
import { v } from '$lib';
import { m } from '$lib/paraglide/messages';
const username = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.minLength(3, m.errors_username_too_short({ min: 3 }))
);
const email = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.email(m.errors_email_invalid())
);
const password = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.minLength(8, m.errors_password_too_short({ min: 8 }))
);
const newPassword = v.pipe(
v.string(m.errors_non_empty()),
v.nonEmpty(m.errors_non_empty()),
v.minLength(8, m.errors_password_too_short({ min: 8 })),
v.check((p) => /[a-z]/.test(p) && /[A-Z]/.test(p) && /\d/.test(p), m.errors_password_weak())
);
const confirm = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()));
export const loginSchema = v.object({
_password: password,
rememberMe: v.pipe(
v.optional(v.string(), 'no'),
v.transform((x) => x === 'yes')
),
username
});
export const registerSchema = v.pipe(
v.object({ _confirm: confirm, _password: newPassword, email, username }),
v.forward(
v.partialCheck(
[['_password'], ['_confirm']],
(input) => input._password === input._confirm,
m.errors_passwords_no_match()
),
['_confirm']
)
);
export const resetRequestSchema = v.object({ email });
export const resetPasswordSchema = v.pipe(
v.object({ _confirm: confirm, newPassword, token: v.string() }),
v.forward(
v.partialCheck(
[['newPassword'], ['_confirm']],
(input) => input.newPassword === input._confirm,
m.errors_passwords_no_match()
),
['_confirm']
)
);
const role = v.picklist(['user', 'admin']);
export const createUserSchema = v.object({
_password: newPassword,
email,
name: v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty())),
role,
username
});
export const updateUserSchema = v.object({
email,
id: v.pipe(v.string(), v.nonEmpty()),
name: v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty())),
role,
username
});
export const banUserSchema = v.object({
banReason: v.optional(v.string(), ''),
id: v.pipe(v.string(), v.nonEmpty())
});
export const inviteUserSchema = v.object({
email,
name: v.optional(v.string(), ''),
role,
username: v.optional(v.string(), '')
});
+106
View File
@@ -0,0 +1,106 @@
import { dash } from '@better-auth/infra';
import { getRequestEvent } from '$app/server';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { emailer } from '$lib/server/emails';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, twoFactor, username } from 'better-auth/plugins';
import { genericOAuth, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import path from 'node:path';
import { cwd } from 'node:process';
export const oauthConfig = (await Bun.file(
path.join(cwd(), 'config/oauth.json')
).json()) as GenericOAuthConfig[];
export const auth = betterAuth({
basePath: '/api/auth',
baseURL: env.ORIGIN,
database: drizzleAdapter(db, {
provider: 'sqlite',
schema
}),
emailAndPassword: {
autoSignIn: false,
customSyntheticUser: ({ additionalFields, coreFields, id }) => ({
...coreFields,
banExpires: null,
banned: false,
banReason: null,
displayUsername: null,
role: 'user',
twoFactorEnabled: false,
username: null,
...additionalFields,
id
}),
disableSignUp: env.DISABLE_SIGNUP || false,
enabled: env.ENABLE_EMAIL_AND_PASSWORD || true,
requireEmailVerification: true,
sendResetPassword: async ({ token, url, user }) => {
if (url.endsWith('reset-password')) await emailer.sendResetPassword({ token, url, user });
if (url.endsWith('complete-registration'))
await emailer.sendCompleteRegistration({ token, url, user });
}
},
emailVerification: {
autoSignInAfterVerification: true,
sendOnSignIn: true,
sendOnSignUp: true,
sendVerificationEmail: async ({ url, user }) => {
await emailer.sendVerificationEmail({ url, user });
}
},
plugins: [
admin(),
dash(),
genericOAuth({ config: oauthConfig }),
twoFactor({
issuer: m.appname(),
otpOptions: {
sendOTP: async ({ otp, user }) => {
await emailer.sendOtp({ otp, user });
}
},
totpOptions: {
period: 30
}
}),
username(),
sveltekitCookies(getRequestEvent)
],
rateLimit: { enabled: true },
socialProviders: {
facebook:
(process.env.FACEBOOK_CLIENT_ID && {
clientId: process.env.FACEBOOK_CLIENT_ID as string,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET as string
}) ||
undefined,
github:
(process.env.GITHUB_CLIENT_ID && {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
}) ||
undefined,
google:
(process.env.GOOGLE_CLIENT_ID && {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
}) ||
undefined
},
telemetry: { enabled: false },
user: {
deleteUser: {
enabled: true
}
}
});
export type Auth = typeof auth;
@@ -0,0 +1,92 @@
<script lang="ts">
import GlobeIcon from '@lucide/svelte/icons/globe';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import MoonIcon from '@lucide/svelte/icons/moon';
import SettingsIcon from '@lucide/svelte/icons/settings';
import SunIcon from '@lucide/svelte/icons/sun';
import { Button } from '$lib/components/ui/button';
import * as ButtonGroup from '$lib/components/ui/button-group';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { getLocale, locales, setLocale } from '$lib/paraglide/runtime';
import { setMode, userPrefersMode } from 'mode-watcher';
const current = $derived(getLocale());
const names = $derived(new Intl.DisplayNames([current], { type: 'language' }));
const labelOf = (l: string) => names.of(l) ?? l;
const pickLocale = (l: ReturnType<typeof getLocale>) => setLocale(l);
</script>
{#snippet localePicker()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" class="gap-1">
<GlobeIcon class="size-4" />
<span>{labelOf(current)}</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#each locales as l (l)}
<DropdownMenu.Item onclick={() => pickLocale(l)}>{labelOf(l)}</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
{#snippet themePicker()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" aria-label={m.theme()}>
{#if userPrefersMode.current === 'light'}
<SunIcon class="size-4" />
{:else if userPrefersMode.current === 'dark'}
<MoonIcon class="size-4" />
{:else}
<MonitorIcon class="size-4" />
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => setMode('light')}>
<SunIcon class="size-4" /> {m.theme_light()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('dark')}>
<MoonIcon class="size-4" /> {m.theme_dark()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('system')}>
<MonitorIcon class="size-4" /> {m.theme_system()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
<div class="hidden md:flex">
<ButtonGroup.Root>
{@render localePicker()}
<ButtonGroup.Separator />
{@render themePicker()}
</ButtonGroup.Root>
</div>
<div class="md:hidden">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" aria-label={m.settings()}>
<SettingsIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="flex w-auto flex-col gap-1 p-2" align="end">
{@render localePicker()}
{@render themePicker()}
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Breadcrumb from '$lib/components/ui/breadcrumb';
import { m } from '$lib/paraglide/messages';
const LABELS: Record<string, () => string> = {
'/': m.home,
'/admin': m.nav_admin,
'/admin/config': m.nav_admin_config,
'/admin/users': m.nav_admin_users,
'/dashboard': m.dashboard,
'/system': m.nav_system,
'/system/date-time': m.nav_system_datetime,
'/system/localization': m.nav_system_localization
};
const titleCase = (segment: string) =>
segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const crumbs = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean);
const segments = parts.map((segment, i) => {
const href = '/' + parts.slice(0, i + 1).join('/');
return { href, label: LABELS[href]?.() ?? titleCase(segment) };
});
return [{ href: '/', label: LABELS['/']?.() ?? m.home() }, ...segments];
});
</script>
<Breadcrumb.Root>
<Breadcrumb.List>
{#each crumbs as crumb, i (crumb.href)}
{@const last = i === crumbs.length - 1}
{#if i > 0}
<Breadcrumb.Separator class="hidden md:block" />
{/if}
<Breadcrumb.Item class={last ? '' : 'hidden md:block'}>
{#if last}
<Breadcrumb.Page>{crumb.label}</Breadcrumb.Page>
{:else}
<Breadcrumb.Link href={resolve(crumb.href as Pathname)}>{crumb.label}</Breadcrumb.Link>
{/if}
</Breadcrumb.Item>
{/each}
</Breadcrumb.List>
</Breadcrumb.Root>
@@ -0,0 +1,234 @@
<script lang="ts" module>
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import ServerIcon from '@lucide/svelte/icons/server';
import ShieldIcon from '@lucide/svelte/icons/shield';
import favicon from '$lib/assets/favicon.svg?raw';
import { m } from '$lib/paraglide/messages';
const data: {
navMain: {
icon: typeof ShieldIcon;
items: {
description: () => string;
title: () => string;
url: Pathname;
}[];
title: () => string;
url: Pathname;
}[];
} = {
navMain: [
{
icon: LayoutDashboardIcon,
items: [
{
description: m.nav_dashboard_overview_desc,
title: m.nav_dashboard_overview,
url: '/dashboard'
}
],
title: m.dashboard,
url: '/dashboard'
},
{
icon: ShieldIcon,
items: [
{
description: m.nav_admin_users_desc,
title: m.nav_admin_users,
url: '/admin/users'
},
{
description: m.nav_admin_config_desc,
title: m.nav_admin_config,
url: '/admin/config'
}
],
title: m.nav_admin,
url: '/admin'
},
{
icon: ServerIcon,
items: [
{
description: m.nav_system_datetime_desc,
title: m.nav_system_datetime,
url: '/system/date-time'
},
{
description: m.nav_system_localization_desc,
title: m.nav_system_localization,
url: '/system/localization'
}
],
title: m.nav_system,
url: '/system'
}
]
};
</script>
<script lang="ts">
import type { Pathname } from '$app/types';
import type { ComponentProps } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Sheet from '$lib/components/ui/sheet';
import * as Sidebar from '$lib/components/ui/sidebar';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import MachinesNav from './machines-nav.svelte';
import NavUser from './nav-user.svelte';
const isMobile = new IsMobile();
const sidebar = useSidebar();
let mobileSection = $state<(typeof data.navMain)[number] | null>(null);
// Navigating from any link in either drawer should drop the user on the page,
// not leave the rail + content sheets covering it.
const closeMobileSidebars = () => {
mobileSection = null;
sidebar.setOpenMobile(false);
};
let {
ref = $bindable(null),
user,
...restProps
}: { user: User } & ComponentProps<typeof Sidebar.Root> = $props();
const activeItem = $derived(
data.navMain.find((section) => page.url.pathname.startsWith(section.url)) ?? data.navMain[0]!
);
</script>
{#snippet sectionContent(section: (typeof data.navMain)[number])}
{#if section.url === '/dashboard'}
<MachinesNav onnavigate={closeMobileSidebars} />
{:else}
{#each section.items as item (item.url)}
<a
href={resolve(item.url)}
data-active={page.url.pathname === item.url}
onclick={closeMobileSidebars}
class="transition-all group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 p-4 text-sm leading-tight"
>
<span class="transition-all font-medium">{item.title()}</span>
<span
class="text-foreground transition-all group-data-[active=true]/link:text-tertiary-foreground group-hover/link:text-tertiary-foreground line-clamp-2 text-xs whitespace-break-spaces"
>
{item.description()}
</span>
</a>
{/each}
{/if}
{/snippet}
<Sidebar.Root
bind:ref
collapsible="icon"
class="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
{...restProps}
>
<Sidebar.Root collapsible="none" class="w-[calc(var(--sidebar-width-icon)+1px)]! border-e">
<Sidebar.Header class="h-15! flex-row items-center">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
size="lg"
class="md:h-8! active:bg-transparent md:p-0! hover:bg-muted/20 hover:text-muted-foreground"
>
{#snippet child({ props })}
<a href={resolve('/')} {...props} onclick={closeMobileSidebars}>
<div
class="text-sidebar-primary flex aspect-square size-8! items-center justify-center rounded-lg"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html favicon}
</div>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{m.appname()}</span>
</div>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupContent class="px-1.5 md:px-0">
<Sidebar.Menu class="space-y-2">
{#each data.navMain as item (item.url)}
<Sidebar.MenuItem>
<Sidebar.MenuButton
tooltipContentProps={{ hidden: false }}
isActive={activeItem.url === item.url}
class="px-2.5 md:px-2"
>
{#snippet tooltipContent()}
{item.title()}
{/snippet}
{#snippet child({ props })}
<a
href={resolve(item.url)}
{...props}
onclick={(e) => {
// On mobile, open the second drawer instead of navigating —
// lets the user pick a sub-item before committing to a route.
if (isMobile.current && !e.metaKey && !e.ctrlKey && e.button === 0) {
e.preventDefault();
mobileSection = item;
}
}}
>
<item.icon />
<span>{item.title()}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<NavUser {user} />
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Root collapsible="none" class="hidden flex-1 md:flex">
<Sidebar.Header class="gap-3.5 border-b p-4 h-15! flex-row items-center">
<div class="text-foreground text-base font-medium">{activeItem.title()}</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group class="px-0 ps-2">
<Sidebar.GroupContent>
{@render sectionContent(activeItem)}
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
</Sidebar.Root>
</Sidebar.Root>
<!-- Mobile secondary drawer: opens when the user taps a section icon in the main
sheet. Desktop never reaches this (md:hidden via the rail's click guard). -->
<Sheet.Root
open={mobileSection !== null}
onOpenChange={(v) => {
if (!v) mobileSection = null;
}}
>
<Sheet.Content side="left" class="w-(--sidebar-width) p-0 sm:max-w-none">
<Sheet.Header class="h-15! flex-row items-center border-b p-4">
<Sheet.Title>{mobileSection?.title()}</Sheet.Title>
</Sheet.Header>
<div class="flex flex-col px-0 ps-2 overflow-y-auto">
{#if mobileSection}
{@render sectionContent(mobileSection)}
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,184 @@
<script lang="ts">
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Drawer from '$lib/components/ui/drawer';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { addMachine, listMachines } from '$lib/remotes/machines.remote';
import { auditLog, serverInfo, systemDetails } from '$lib/remotes/server.remote';
import { tick } from 'svelte';
import { toast } from 'svelte-sonner';
const info = $derived(serverInfo());
const details = $derived(systemDetails());
const audit = $derived(auditLog());
const id = $props.id();
const isMobile = new IsMobile();
let { onnavigate }: { onnavigate?: () => void } = $props();
// MediaQuery is false during SSR/first hydration; gate on mount so server and
// client agree on the Dialog branch, then switch to Drawer. Avoids hydration_mismatch.
let search = $state('');
let pageNum = $state(1);
let open = $state(false);
const onSearch = () => {
pageNum = 1;
};
const selectedId = $derived(page.params.machineId);
const machines = $derived(listMachines({ page: pageNum, search }));
</script>
{#snippet addForm()}
<form
oninput={() => addMachine.validate()}
{...addMachine.preflight(machineSchema).enhance(async ({ submit }) => {
try {
await submit();
await listMachines({ page: pageNum, search }).refresh();
open = false;
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="name-{id}">{m.machine_name()}</Field.Label>
<Input
id="name-{id}"
placeholder={m.machine_name_placeholder()}
{...addMachine.fields.name.as('text')}
required
/>
{#each addMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="address-{id}">{m.machine_address()}</Field.Label>
<Input
id="address-{id}"
placeholder={m.machine_address_placeholder()}
{...addMachine.fields.address.as('text')}
/>
{#each addMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="token-{id}">{m.machine_token()}</Field.Label>
<Input
id="token-{id}"
placeholder={m.machine_token_placeholder()}
{...addMachine.fields.token.as('password')}
required
/>
{#each addMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!addMachine.pending}>{m.machine_save()}</Button>
</Field.Group>
</form>
{/snippet}
<div class="flex flex-col gap-2 p-2">
<Input bind:value={search} oninput={onSearch} placeholder={m.machine_search_placeholder()} />
{#if isMobile.current}
<Drawer.Root bind:open>
<Drawer.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="w-full justify-start">
<PlusIcon class="size-4" />
{m.machine_add()}
</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header class="text-left">
<Drawer.Title>{m.machine_add()}</Drawer.Title>
<Drawer.Description>{m.machine_add_description()}</Drawer.Description>
</Drawer.Header>
<div class="p-4">{@render addForm()}</div>
</Drawer.Content>
</Drawer.Root>
{:else}
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="w-full justify-start">
<PlusIcon class="size-4" />
{m.machine_add()}
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_add()}</Dialog.Title>
<Dialog.Description>{m.machine_add_description()}</Dialog.Description>
</Dialog.Header>
{@render addForm()}
</Dialog.Content>
</Dialog.Root>
{/if}
</div>
<svelte:boundary>
{@const data = await machines}
<div class="flex flex-col">
{#each data.items as machine (machine.id)}
<a
href={resolve(`/dashboard/[machineId]`, { machineId: machine.id })}
onclick={async () => {
onnavigate?.();
await tick().then(async () => {
await details.refresh();
await audit.refresh();
await info.refresh();
});
}}
data-active={selectedId === machine.id}
class="group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 border-b p-4 text-sm leading-tight transition-all last:border-b-0"
>
<span class="font-medium transition-all">{machine.name}</span>
<span
class="text-foreground group-hover/link:text-tertiary-foreground group-data-[active=true]/link:text-tertiary-foreground transition-all text-xs"
>{machine.address}</span
>
</a>
{:else}
<p class="text-muted-foreground p-4 text-sm">{m.machine_none()}</p>
{/each}
</div>
{#if data.pages > 1}
<div class="flex items-center justify-between gap-2 p-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onclick={() => (pageNum = data.page - 1)}>{m.pagination_previous()}</Button
>
<span class="text-muted-foreground text-xs"
>{m.pagination_page_of({ page: data.page, pages: data.pages })}</span
>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.pages}
onclick={() => (pageNum = data.page + 1)}>{m.pagination_next()}</Button
>
</div>
{/if}
</svelte:boundary>
@@ -0,0 +1,82 @@
<script lang="ts">
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import { invalidateAll } from '$app/navigation';
import { getAuthClient } from '$lib/auth/client';
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { m } from '$lib/paraglide/messages';
let { user }: { user: User } = $props();
const sidebar = useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="data-[state=open]:bg-sidebar-accent md:data-[state=open]:bg-transparent data-[state=open]:text-sidebar-accent-foreground md:active:bg-transparent md:hover:bg-transparent rounded-lg h-12 md:size-8! flex p-2 md:p-0!"
>
{#snippet child({ props })}
<Sidebar.MenuButton {...props}>
<Avatar.Root class="size-8! rounded-none! overflow-clip after:rounded-none!">
<Avatar.Image
class="size-8! shrink-0 rounded-none!"
src={user.image}
alt={user.name}
/>
<Avatar.Fallback class="rounded-none! shrink-0 size-8!"
>{user.name.slice(0, 1)}</Avatar.Fallback
>
</Avatar.Root>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon class="ms-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<Avatar.Root class="size-8 rounded-lg after:rounded-lg">
<Avatar.Image src={user.image} alt={user.name} />
<Avatar.Fallback class="rounded-lg">{user.name.slice(0, 1)}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<BadgeCheckIcon />
{m.account()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={async () => {
const authClient = getAuthClient();
await authClient.signOut();
await invalidateAll();
}}
>
<LogOutIcon />
{m.logout()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -0,0 +1,202 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
backgroundColor?: number | string;
chaos?: number;
children?: import('svelte').Snippet;
color?: number | string;
spacing?: number;
}
let {
backgroundColor = 'var(--background)',
chaos = 1,
children,
color = 'oklch(from var(--secondary) l c h / 50%)',
spacing = 2.5
}: Props = $props();
let container = $state<HTMLDivElement>();
onMount(() => {
if (!container) return;
const el = container;
const canvas = document.createElement('canvas');
canvas.classList.add('fade-in', 'animate-in');
const ctx = canvas.getContext('2d')!;
el.appendChild(canvas);
// number(hex) | string(qualsiasi CSS color: var(...), oklch(...), #fff, red...) -> "rgb(...)"
const resolveColor = (c: number | string): string => {
let input: string;
if (typeof c === 'number') {
input = `#${c.toString(16).padStart(6, '0')}`;
} else if (c.trim().startsWith('--')) {
input = getComputedStyle(el).getPropertyValue(c.trim()).trim();
} else {
input = c;
}
const probe = document.createElement('span');
probe.style.color = input;
el.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved || input;
};
const strokeStyle = resolveColor(color);
const bgStyle = resolveColor(backgroundColor);
let h = 0,
w = 0;
const resize = (): void => {
w = canvas.width = el.clientWidth;
h = canvas.height = el.clientHeight;
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
// --- Perlin noise 3D, range 0..1 come p.noise() ---
const perm = new Uint8Array(512);
const p: number[] = [...Array(256).keys()];
for (let i = 255; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0;
const pi = p[i];
const pj = p[j];
if (pi !== undefined && pj !== undefined) {
p[i] = pj;
p[j] = pi;
}
}
for (let i = 0; i < 512; i++) {
const val = p[i & 255];
if (val !== undefined) {
perm[i] = val;
}
}
const fade = (t: number): number => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (t: number, a: number, b: number): number => a + t * (b - a);
const grad = (hash: number, x: number, y: number, z: number): number => {
const hh = hash & 15;
const u = hh < 8 ? x : y;
const v = hh < 4 ? y : hh === 12 || hh === 14 ? x : z;
return ((hh & 1) === 0 ? u : -u) + ((hh & 2) === 0 ? v : -v);
};
const noise = (x: number, y: number, z: number): number => {
const X = Math.floor(x) & 255,
Y = Math.floor(y) & 255,
Z = Math.floor(z) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
z -= Math.floor(z);
const u = fade(x),
v = fade(y),
ww = fade(z);
const A = perm[X]! + Y,
AA = perm[A]! + Z,
AB = perm[A + 1]! + Z;
const B = perm[X + 1]! + Y,
BA = perm[B]! + Z,
BB = perm[B + 1]! + Z;
const n = lerp(
ww,
lerp(
v,
lerp(u, grad(perm[AA]!, x, y, z), grad(perm[BA]!, x - 1, y, z)),
lerp(u, grad(perm[AB]!, x, y - 1, z), grad(perm[BB]!, x - 1, y - 1, z))
),
lerp(
v,
lerp(u, grad(perm[AA + 1]!, x, y, z - 1), grad(perm[BA + 1]!, x - 1, y, z - 1)),
lerp(u, grad(perm[AB + 1]!, x, y - 1, z - 1), grad(perm[BB + 1]!, x - 1, y - 1, z - 1))
)
);
return (n + 1) / 2;
};
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
const rings = isMobile ? 35 : 55;
const dimInit = 50;
const dimDelta = 4;
const chaosInit = 0.2;
const chaosDelta = 0.12;
const chaosMag = 20;
let ox = Math.random() * 10000;
let oy = Math.random() * 10000;
let oz = Math.random() * 10000;
const TWO_PI = Math.PI * 2;
const getNoiseWithTime = (radian: number, dim: number, time: number): number => {
let r = radian % TWO_PI;
if (r < 0) r += TWO_PI;
return noise(ox + Math.cos(r) * dim, oy + Math.sin(r) * dim, oz + time);
};
let raf = 0;
const draw = (): void => {
ctx.fillStyle = bgStyle;
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = 1;
oy -= 0.02;
oz += 0.00005;
for (let i = 0; i < rings; i++) {
const dim = chaosDelta * i + chaosInit;
ctx.beginPath();
for (let angle = 0; angle < 360; angle++) {
const radian = (angle * Math.PI) / 180;
const radius =
chaos * chaosMag * getNoiseWithTime(radian, dim, oz) +
(dimDelta * i + dimInit) +
i * spacing;
const x = radius * Math.cos(radian);
const y = radius * Math.sin(radian);
if (angle === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
ctx.restore();
raf = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
canvas.remove();
};
});
</script>
<div bind:this={container} class="trunk bg-muted">
{@render children?.()}
</div>
<style>
.trunk {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.trunk :global(canvas) {
display: block;
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 0;
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import { History } from '@lucide/svelte';
import { m } from '$lib/paraglide/messages';
import type { LogEntry } from './types';
import DataPanel from './data-panel.svelte';
import { fmtTime, statusPill } from './format';
let { items }: { items: LogEntry[] } = $props();
</script>
<DataPanel
title={m.dashboard_recent_activity()}
icon={History}
{items}
pageSize={10}
initialDir="desc"
columns={[
{ get: (e) => e.time, key: 'time', label: m.dashboard_col_time() },
{ get: (e) => e.status, key: 'status', label: m.dashboard_col_status() },
{ get: (e) => e.method, key: 'method', label: m.dashboard_col_method() },
{ get: (e) => e.path, key: 'path', label: m.dashboard_col_path() },
{ get: (e) => e.username, key: 'user', label: m.dashboard_col_user() }
]}
search={(e, q) =>
e.path.toLowerCase().includes(q) ||
e.method.toLowerCase().includes(q) ||
e.username.toLowerCase().includes(q) ||
String(e.status).includes(q)}
>
{#snippet row(entry, i)}
<div class="flex items-center gap-3 py-2 text-sm {i > 0 ? 'border-border/60 border-t' : ''}">
<span
class="flex h-5 w-10 shrink-0 items-center justify-center rounded text-xs font-semibold tabular-nums {statusPill(
entry.status
)}">{entry.status}</span
>
<span class="text-muted-foreground w-12 shrink-0 font-mono text-xs">{entry.method}</span>
<span class="grow truncate font-mono text-xs">{entry.path}</span>
<span class="text-muted-foreground hidden sm:inline">{entry.username}</span>
<span class="text-muted-foreground shrink-0 tabular-nums">{fmtTime(entry.time)}</span>
</div>
{/snippet}
</DataPanel>
@@ -0,0 +1,112 @@
<script lang="ts">
import { Cpu } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { ghz } from './format';
let {
cpuModel,
cpuUsage,
currentMhz,
logicalCpus,
maxMhz,
minMhz
}: {
cpuModel: string;
cpuUsage: { core: number; usage_pct: number }[] | null;
currentMhz: number;
logicalCpus: number;
maxMhz: number;
minMhz: number;
} = $props();
const cores = $derived.by(() => {
const arr = new Array(logicalCpus).fill(0).map((_, i) => ({
core: i,
usage_pct: 0
}));
if (cpuUsage) {
for (const u of cpuUsage) {
if (u.core >= 0 && u.core < logicalCpus) {
const target = arr[u.core];
if (target) {
target.usage_pct = u.usage_pct;
}
}
}
}
return arr;
});
const avgLoad = $derived(
cores.length > 0 ? Math.round(cores.reduce((sum, c) => sum + c.usage_pct, 0) / cores.length) : 0
);
function getCoreColorClass(pct: number): string {
if (pct < 10) return 'bg-lime-500/10';
if (pct < 20) return 'bg-lime-500/20';
if (pct < 30) return 'bg-lime-500/30';
if (pct < 40) return 'bg-amber-500/40';
if (pct < 50) return 'bg-amber-500/50';
if (pct < 60) return 'bg-amber-500/60';
if (pct < 70) return 'bg-amber-500/70';
if (pct < 80) return 'bg-red-500/80';
if (pct < 90) return 'bg-red-500/90';
return 'bg-red-500';
}
</script>
<Card.Root class="gap-3 h-full">
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Cpu class="size-4" />
</span>
<Card.Title class="leading-1">{m.dashboard_cpu()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-1 flex-col justify-center gap-3">
<div class="flex items-baseline justify-between">
<div
class="text-xl font-semibold tracking-tight tabular-nums {avgLoad >= 70
? 'text-red-500'
: avgLoad >= 30
? 'text-amber-500'
: 'text-emerald-500'}"
>
{avgLoad}%
</div>
<div class="text-[11px] text-muted-foreground truncate max-w-[180px]" title={cpuModel}>
{cpuModel}
</div>
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(60px,1fr))] gap-2 w-full">
{#each cores as c (c.core)}
<div
class="{getCoreColorClass(
c.usage_pct
)} group/square aspect-square rounded-[2px] border border-border transition-all duration-150 hover:z-10 hover:border-2 hover:border-primary flex flex-col"
>
<div
class="group-hover/square:opacity-100 opacity-0 transition-all flex justify-center items-center gap-0 aspect-square"
>
<span class="font-bold"> {c.usage_pct.toFixed(0)}%</span>
</div>
</div>
{/each}
</div>
<div class="text-muted-foreground text-xs mt-auto">
{m.dashboard_cpu_detail({
cores: logicalCpus,
current: ghz(currentMhz),
max: ghz(maxMhz),
min: ghz(minMhz)
})}
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,138 @@
<script lang="ts" generics="T">
import type { Component, Snippet } from 'svelte';
import { ArrowDown, ArrowUp, Search } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils';
type Column = { get: (item: T) => number | string; key: string; label: string };
let {
class: classes,
columns,
icon: Icon,
initialDir = 'asc',
initialSort = 0,
items,
pageSize = 8,
row,
search,
title
}: {
class?: string | string[];
columns: Column[];
icon: Component<{ class?: string }>;
initialDir?: 'asc' | 'desc';
initialSort?: number;
items: T[];
pageSize?: number;
row: Snippet<[T, number]>;
search?: (item: T, q: string) => boolean;
title: string;
} = $props();
// svelte-ignore state_referenced_locally
let sortIdx = $state(initialSort);
// svelte-ignore state_referenced_locally
let dir = $state<'asc' | 'desc'>(initialDir);
let page = $state(0);
let q = $state('');
const filtered = $derived(
search && q.trim() ? items.filter((it) => search(it, q.trim().toLowerCase())) : items
);
const sorted = $derived.by(() => {
const col = columns[sortIdx];
if (!col) return filtered;
const f = dir === 'asc' ? 1 : -1;
return [...filtered].sort((a, b) => {
const x = col.get(a);
const y = col.get(b);
const c =
typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y));
return c * f;
});
});
const pages = $derived(Math.max(1, Math.ceil(sorted.length / pageSize)));
const slice = $derived(sorted.slice(page * pageSize, (page + 1) * pageSize));
$effect(() => {
if (page > pages - 1) page = pages - 1;
});
</script>
<Card.Root class={cn('min-h-max h-full', classes)}>
<Card.Header class="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0">
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Icon class="size-4" />
</span>
<Card.Title class="leading-1">{title}</Card.Title>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if search}
<div class="relative">
<Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
<Input
bind:value={q}
placeholder={m.dashboard_search_placeholder()}
class="h-8 w-36 pl-7"
/>
</div>
{/if}
<Select.Root
type="single"
value={String(sortIdx)}
onValueChange={(v) => (sortIdx = Number(v))}
>
<Select.Trigger class="h-8">{columns[sortIdx]?.label}</Select.Trigger>
<Select.Content>
{#each columns as col, i (col.key)}
<Select.Item value={String(i)}>{col.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
variant="outline"
size="icon"
class="size-8"
title={dir === 'asc' ? m.dashboard_ascending() : m.dashboard_descending()}
onclick={() => (dir = dir === 'asc' ? 'desc' : 'asc')}
>
{#if dir === 'asc'}<ArrowUp class="size-4" />{:else}<ArrowDown class="size-4" />{/if}
</Button>
</div>
</Card.Header>
<Card.Content class="flex flex-col grow">
{#each slice as item, i (i)}
{@render row(item, i)}
{:else}
<p class="text-muted-foreground py-2 text-sm">{m.dashboard_nothing_to_show()}</p>
{/each}
{#if sorted.length > pageSize}
<div class="text-muted-foreground flex items-center justify-between pt-3 text-xs mt-auto">
<span class="tabular-nums"
>{m.dashboard_pagination_info({
end: Math.min(sorted.length, (page + 1) * pageSize),
start: page * pageSize + 1,
total: sorted.length
})}</span
>
<div class="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 0} onclick={() => page--}
>{m.dashboard_prev()}</Button
>
<Button variant="outline" size="sm" disabled={page >= pages - 1} onclick={() => page++}
>{m.dashboard_next()}</Button
>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
+61
View File
@@ -0,0 +1,61 @@
// Single source of truth for the dashboard's data shaping + color semantics.
// ponytail: GB (10^9), not GiB — fine for a monitoring readout
export const gb = (bytes: number) => (bytes / 1e9).toFixed(1) + ' GB';
export const ghz = (mhz: number) => (mhz / 1000).toFixed(1) + ' GHz';
export const pct = (used: number, total: number) => (total ? Math.round((used / total) * 100) : 0);
export function uptime(seconds: number) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return [d && `${d}d`, h && `${h}h`, `${m}m`].filter(Boolean).join(' ');
}
// first IPv4 (no colon), address only
export const ipv4 = (addrs: null | string[]) => addrs?.find((a) => !a.includes(':'))?.split('/')[0];
// TZ pinned to UTC so SSR (Node host TZ) and first client paint produce identical
// strings — the locale comes from paraglide, so it matches whatever the user picked.
// ponytail: swap timeZone to the browser's TZ post-mount if you want local time.
import { getLocale } from '$lib/paraglide/runtime';
const DT_FMT: Intl.DateTimeFormatOptions = {
day: '2-digit',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'UTC',
year: 'numeric'
};
const TIME_FMT: Intl.DateTimeFormatOptions = {
hour: '2-digit',
hour12: false,
minute: '2-digit',
second: '2-digit',
timeZone: 'UTC'
};
export const fmtTime = (t: string) => new Date(t).toLocaleTimeString(getLocale(), TIME_FMT);
export const fmtDateTime = (t: string) => new Date(t).toLocaleString(getLocale(), DT_FMT);
// severity → color. green healthy, amber warning, red critical. Used everywhere.
export const usageText = (p: number) =>
p >= 90 ? 'text-red-500' : p >= 75 ? 'text-amber-500' : 'text-emerald-500';
export const usageBar = (p: number) =>
p >= 90
? '*:data-[slot=progress-indicator]:bg-red-500'
: p >= 75
? '*:data-[slot=progress-indicator]:bg-amber-500'
: '*:data-[slot=progress-indicator]:bg-emerald-500';
export const tempText = (c: number) =>
c >= 80 ? 'text-red-500' : c >= 65 ? 'text-amber-500' : 'text-emerald-500';
export const statusPill = (s: number) =>
s === 0 || s >= 500
? 'bg-red-500/15 text-red-500'
: s >= 400
? 'bg-amber-500/15 text-amber-600 dark:text-amber-500'
: s >= 300
? 'bg-blue-500/15 text-blue-500'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-500';
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import * as Card from '$lib/components/ui/card';
let {
detail,
extra,
icon: Icon,
label,
value,
valueClass = ''
}: {
detail?: string;
extra?: Snippet;
icon: Component<{ class?: string }>;
label: string;
value: string;
valueClass?: string;
} = $props();
</script>
<Card.Root class="gap-3">
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Icon class="size-4" />
</span>
<Card.Title class="leading-1">{label}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-1 flex-col justify-center gap-2">
<div class="text-xl font-semibold tracking-tight text-balance {valueClass}">{value}</div>
{#if detail}<div class="text-muted-foreground text-sm mt-auto text-balance">{detail}</div>{/if}
{@render extra?.()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,45 @@
<script lang="ts">
import { Network } from '@lucide/svelte';
import { m } from '$lib/paraglide/messages';
import type { NetIface } from './types';
import DataPanel from './data-panel.svelte';
import { ipv4 } from './format';
let { items }: { items: NetIface[] } = $props();
</script>
<DataPanel
title={m.dashboard_network()}
icon={Network}
pageSize={9}
{items}
columns={[
{ get: (i) => i.name, key: 'name', label: m.dashboard_col_name() },
{ get: (i) => ipv4(i.addresses) ?? '', key: 'ip', label: m.dashboard_col_ip() },
{ get: (i) => (i.up ? 0 : 1), key: 'status', label: m.dashboard_col_status() }
]}
>
{#snippet row(iface, i)}
<div
class="flex items-center justify-between gap-3 py-2 text-sm {i > 0
? 'border-border/60 border-t'
: ''}"
>
<span class="font-medium">{iface.name}</span>
<span class="text-muted-foreground grow truncate text-right font-mono text-xs"
>{ipv4(iface.addresses) ?? '—'}</span
>
<span
class="flex items-center gap-1.5 text-xs font-medium {iface.up
? 'text-emerald-500'
: 'text-muted-foreground'}"
>
<span class="size-1.5 rounded-full {iface.up ? 'bg-emerald-500' : 'bg-muted-foreground'}"
></span>
{iface.up ? m.dashboard_status_up() : m.dashboard_status_down()}
</span>
</div>
{/snippet}
</DataPanel>
@@ -0,0 +1,45 @@
<script lang="ts">
import { HardDrive } from '@lucide/svelte';
import { Progress } from '$lib/components/ui/progress';
import { m } from '$lib/paraglide/messages';
import type { Disk } from './types';
import DataPanel from './data-panel.svelte';
import { gb, pct, usageBar, usageText } from './format';
let { items }: { items: Disk[] } = $props();
</script>
<DataPanel
class="shrink-0 h-max"
title={m.dashboard_storage()}
icon={HardDrive}
{items}
initialDir="desc"
columns={[
{ get: (d) => pct(d.used_bytes, d.total_bytes), key: 'usage', label: m.dashboard_col_usage() },
{ get: (d) => d.mountpoint, key: 'mount', label: m.dashboard_col_mount() },
{ get: (d) => d.free_bytes, key: 'free', label: m.dashboard_col_free() },
{ get: (d) => d.total_bytes, key: 'size', label: m.dashboard_col_size() }
]}
>
{#snippet row(disk, i)}
{@const used = pct(disk.used_bytes, disk.total_bytes)}
<div class="flex flex-col gap-1.5 py-2 {i > 0 ? 'border-border/60 border-t' : ''}">
<div class="flex items-baseline justify-between gap-2 text-sm">
<span class="font-medium">{disk.mountpoint}</span>
<span class="text-muted-foreground font-mono text-xs"
>{disk.filesystem} · {disk.fstype}</span
>
</div>
<Progress value={used} class={usageBar(used)} />
<div class="flex justify-between text-xs tabular-nums">
<span class="font-medium {usageText(used)}">{m.dashboard_used_percent({ used })}</span>
<span class="text-muted-foreground"
>{m.dashboard_free_of({ free: gb(disk.free_bytes), total: gb(disk.total_bytes) })}</span
>
</div>
</div>
{/snippet}
</DataPanel>
@@ -0,0 +1,117 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Server } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { SystemDetails } from './types';
import { ghz } from './format';
let {
cpu,
details,
os
}: {
cpu: { logical_cpus: number; max_mhz: number; min_mhz: number; model: string };
details: SystemDetails;
os: { architecture: string; kernel: string };
} = $props();
</script>
{#snippet field(
label: string,
value: string,
opts: { class: string; mono: boolean } = { class: '', mono: false }
)}
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-muted-foreground text-xs">{label}</span>
<span
class="truncate text-right {opts.mono ? 'font-mono text-xs' : 'font-medium'} {opts.class ??
''}">{value}</span
>
</div>
{/snippet}
{#snippet section(heading: string, body: Snippet)}
<div class="flex flex-col gap-2">
<span class="text-foreground text-[0.7rem] font-medium tracking-wide uppercase">{heading}</span>
{@render body()}
</div>
{/snippet}
{#snippet cpuBody()}
{@render field(m.dashboard_cpu(), cpu.model)}
{@render field(
m.dashboard_logical_cores({ cores: cpu.logical_cpus }),
`${ghz(cpu.min_mhz)}${ghz(cpu.max_mhz)}`,
{ class: '', mono: true }
)}
{/snippet}
{#snippet osBody()}
{@render field(m.dashboard_kernel(), os.kernel, { class: '', mono: true })}
{@render field(m.dashboard_architecture(), os.architecture, { class: '', mono: true })}
{/snippet}
{#snippet timeBody()}
{@render field(m.dashboard_timezone(), details.time!.timezone)}
{@render field(
m.dashboard_clock(),
details.time!.ntp_synchronized
? m.dashboard_synchronized()
: details.time!.ntp
? m.dashboard_syncing()
: m.dashboard_not_synced(),
{ class: details.time!.ntp_synchronized ? 'text-emerald-500' : 'text-amber-500', mono: false }
)}
{@render field(
m.dashboard_hardware_clock(),
details.time!.local_rtc ? m.dashboard_local_time() : m.dashboard_utc()
)}
{/snippet}
{#snippet localeBody()}
{@render field(m.dashboard_language(), details.locale!.lang, { class: '', mono: true })}
{@render field(m.dashboard_keymap(), details.locale!.vc_keymap || '—', { class: '', mono: true })}
{/snippet}
{#snippet dnsBody()}
{#each details.dns ?? [] as server (server)}
{@render field(m.dashboard_nameserver(), server, { class: '', mono: true })}
{:else}
{@render field(m.dashboard_nameserver(), m.dashboard_none())}
{/each}
{/snippet}
{#snippet updatesBody()}
{@render field(
details.updates!.manager,
details.updates!.count > 0
? m.dashboard_updates({ count: details.updates!.count })
: m.dashboard_up_to_date(),
{ class: details.updates!.count > 0 ? 'text-amber-500' : 'text-emerald-500', mono: false }
)}
{/snippet}
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Server class="size-4" />
</span>
<Card.Title class="leading-1">{m.dashboard_system()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-col grow gap-4">
{@render section(m.dashboard_cpu(), cpuBody)}
{@render section(m.dashboard_os(), osBody)}
{#if details.time}{@render section(m.dashboard_time(), timeBody)}{/if}
{#if details.locale}{@render section(m.dashboard_locale(), localeBody)}{/if}
{#if details.dns}{@render section(m.dashboard_dns(), dnsBody)}{/if}
{#if details.updates}{@render section(m.dashboard_packages(), updatesBody)}{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,38 @@
<script lang="ts">
import { Thermometer } from '@lucide/svelte';
import { m } from '$lib/paraglide/messages';
import type { Temp } from './types';
import DataPanel from './data-panel.svelte';
import { tempText } from './format';
let { items }: { items: Temp[] } = $props();
const numbered = $derived(items.map((t, i) => ({ ...t, n: i + 1 })));
</script>
<DataPanel
title={m.dashboard_temperatures()}
icon={Thermometer}
items={numbered}
initialDir="desc"
pageSize={9}
columns={[
{ get: (t) => t.celsius, key: 'temp', label: m.dashboard_col_temp() },
{ get: (t) => t.label, key: 'sensor', label: m.dashboard_col_sensor() }
]}
>
{#snippet row(temp, i)}
<div
class="flex items-center justify-between gap-3 py-2 text-sm {i > 0
? 'border-border/60 border-t'
: ''}"
>
<span class="text-muted-foreground">{temp.label} #{temp.n}</span>
<span class="font-semibold tabular-nums {tempText(temp.celsius)}"
>{temp.celsius.toFixed(1)} °C</span
>
</div>
{/snippet}
</DataPanel>
+33
View File
@@ -0,0 +1,33 @@
export type Disk = {
filesystem: string;
free_bytes: number;
fstype: string;
mountpoint: string;
total_bytes: number;
used_bytes: number;
};
export type LogEntry = {
method: string;
module: string;
path: string;
status: number;
time: string;
username: string;
};
export type NetIface = {
addresses: null | string[];
mac: string;
name: string;
up: boolean;
};
export type SystemDetails = {
dns: null | string[];
locale: { lang: string; vc_keymap: string } | null;
time: { local_rtc: boolean; ntp: boolean; ntp_synchronized: boolean; timezone: string } | null;
updates: { count: number; manager: string } | null;
};
export type Temp = { celsius: number; label: string };
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn, type WithoutChild } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
{...restProps}
>
<div
class={cn(
'pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className
)}
>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn('not-last:border-b', className)}
{...restProps}
/>
@@ -0,0 +1,38 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { cn, type WithoutChild } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
children,
class: className,
level = 3,
ref = $bindable(null),
...restProps
}: {
level?: AccordionPrimitive.HeaderProps['level'];
} & WithoutChild<AccordionPrimitive.TriggerProps> = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<ChevronUpIcon
data-slot="accordion-trigger-icon"
class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Accordion as AccordionPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
class={cn('cn-accordion flex w-full flex-col', className)}
{...restProps}
/>
+16
View File
@@ -0,0 +1,16 @@
import Content from './accordion-content.svelte';
import Item from './accordion-item.svelte';
import Trigger from './accordion-trigger.svelte';
import Root from './accordion.svelte';
export {
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
Content,
Item,
Root,
Trigger
};
@@ -0,0 +1,27 @@
<script lang="ts">
import {
type ButtonSize,
type ButtonVariant,
buttonVariants
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
size = 'default',
variant = 'default',
...restProps
}: {
size?: ButtonSize;
variant?: ButtonVariant;
} & AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ size, variant }), 'cn-alert-dialog-action', className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import {
type ButtonSize,
type ButtonVariant,
buttonVariants
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
size = 'default',
variant = 'outline',
...restProps
}: {
size?: ButtonSize;
variant?: ButtonVariant;
} & AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ size, variant }), 'cn-alert-dialog-cancel', className)}
{...restProps}
/>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import AlertDialogPortal from './alert-dialog-portal.svelte';
let {
class: className,
portalProps,
ref = $bindable(null),
size = 'default',
...restProps
}: {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
size?: 'default' | 'sm';
} & WithoutChild<AlertDialogPrimitive.ContentProps> = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
/>
</AlertDialogPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn(
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,40 @@
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Media from './alert-dialog-media.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Portal from './alert-dialog-portal.svelte';
import Title from './alert-dialog-title.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Root from './alert-dialog.svelte';
export {
Action,
//
Root as AlertDialog,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Media as AlertDialogMedia,
Overlay as AlertDialogOverlay,
Portal as AlertDialogPortal,
Title as AlertDialogTitle,
Trigger as AlertDialogTrigger,
Cancel,
Content,
Description,
Footer,
Header,
Media,
Overlay,
Portal,
Root,
Title,
Trigger
};
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-action"
class={cn('absolute top-2 right-2', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
'text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className
)}
{...restProps}
>
{@render children?.()}
</div>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const alertVariants = tv({
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
defaultVariants: {
variant: 'default'
},
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current'
}
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
variant = 'default',
...restProps
}: {
variant?: AlertVariant;
} & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
role="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
+17
View File
@@ -0,0 +1,17 @@
import Action from './alert-action.svelte';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
import Root from './alert.svelte';
export { type AlertVariant, alertVariants } from './alert.svelte';
export {
Action,
//
Root as Alert,
Action as AlertAction,
Description as AlertDescription,
Title as AlertTitle,
Description,
Root,
Title
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { AspectRatio as AspectRatioPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: AspectRatioPrimitive.RootProps = $props();
</script>
<AspectRatioPrimitive.Root bind:ref data-slot="aspect-ratio" {...restProps} />
@@ -0,0 +1,3 @@
import Root from './aspect-ratio.svelte';
export { Root as AspectRatio, Root };
@@ -0,0 +1,27 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="avatar-badge"
class={cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn(
'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs',
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="avatar-group-count"
class={cn(
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="avatar-group"
class={cn(
'cn-avatar-group *:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn('rounded-full aspect-square size-full object-cover', className)}
{...restProps}
/>
@@ -0,0 +1,26 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Avatar as AvatarPrimitive } from 'bits-ui';
let {
class: className,
loadingStatus = $bindable('loading'),
ref = $bindable(null),
size = 'default',
...restProps
}: {
size?: 'default' | 'lg' | 'sm';
} & AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
data-size={size}
class={cn(
'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten',
className
)}
{...restProps}
/>
+22
View File
@@ -0,0 +1,22 @@
import Badge from './avatar-badge.svelte';
import Fallback from './avatar-fallback.svelte';
import GroupCount from './avatar-group-count.svelte';
import Group from './avatar-group.svelte';
import Image from './avatar-image.svelte';
import Root from './avatar.svelte';
export {
//
Root as Avatar,
Badge as AvatarBadge,
Fallback as AvatarFallback,
Group as AvatarGroup,
GroupCount as AvatarGroupCount,
Image as AvatarImage,
Badge,
Fallback,
Group,
GroupCount,
Image,
Root
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none',
defaultVariants: {
variant: 'default'
},
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
destructive:
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80'
}
}
});
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
href,
ref = $bindable(null),
variant = 'default',
...restProps
}: {
variant?: BadgeVariant;
} & WithElementRef<HTMLAnchorAttributes> = $props();
</script>
<svelte:element
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from './badge.svelte';
export { type BadgeVariant, badgeVariants } from './badge.svelte';
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
let {
class: className,
ref = $bindable(null),
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn('size-5 [&>svg]:size-4 flex items-center justify-center', className)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn('gap-1 inline-flex items-center', className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
child,
children,
class: className,
href = undefined,
ref = $bindable(null),
...restProps
}: {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} & WithElementRef<HTMLAnchorAttributes> = $props();
const attrs = $derived({
class: cn('hover:text-foreground transition-colors', className),
'data-slot': 'breadcrumb-link',
href,
...restProps
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLOlAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn(
'text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word',
className
)}
{...restProps}
>
{@render children?.()}
</ol>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn('text-foreground font-normal', className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn('[&>svg]:size-3.5', className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
children,
class: className,
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
aria-label="breadcrumb"
class={cn('cn-breadcrumb', className)}
{...restProps}
>
{@render children?.()}
</nav>
+25
View File
@@ -0,0 +1,25 @@
import Ellipsis from './breadcrumb-ellipsis.svelte';
import Item from './breadcrumb-item.svelte';
import Link from './breadcrumb-link.svelte';
import List from './breadcrumb-list.svelte';
import Page from './breadcrumb-page.svelte';
import Separator from './breadcrumb-separator.svelte';
import Root from './breadcrumb.svelte';
export {
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
Separator as BreadcrumbSeparator,
Ellipsis,
Item,
Link,
List,
Page,
Root,
Separator
};
@@ -0,0 +1,24 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
let {
class: className,
orientation = 'vertical',
ref = $bindable(null),
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn(
'bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto',
className
)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
child,
class: className,
ref = $bindable(null),
...restProps
}: {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const mergedProps = $derived({
...restProps,
class: cn(
"bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className
),
'data-slot': 'button-group-text'
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -0,0 +1,47 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonGroupVariants = tv({
base: "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
defaultVariants: {
orientation: 'horizontal'
},
variants: {
orientation: {
horizontal:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0'
}
}
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
children,
class: className,
orientation = 'horizontal',
ref = $bindable(null),
...restProps
}: {
orientation?: ButtonGroupOrientation;
} & WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
import Separator from './button-group-separator.svelte';
import Text from './button-group-text.svelte';
import Root, { type ButtonGroupOrientation, buttonGroupVariants } from './button-group.svelte';
export {
//
Root as ButtonGroup,
type ButtonGroupOrientation,
Separator as ButtonGroupSeparator,
Text as ButtonGroupText,
buttonGroupVariants,
Root,
Separator,
Text
};
@@ -0,0 +1,92 @@
<script lang="ts" module>
import type { Pathname } from '$app/types';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { resolve } from '$app/paths';
import { cn, type WithElementRef } from '$lib/utils.js';
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
defaultVariants: {
size: 'default',
variant: 'default'
},
variants: {
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-lg': 'size-9',
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3"
},
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
ghost:
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground'
}
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = {
size?: ButtonSize;
variant?: ButtonVariant;
} & WithElementRef<HTMLAnchorAttributes> &
WithElementRef<HTMLButtonAttributes>;
</script>
<script lang="ts">
let {
children,
class: className,
disabled,
href = undefined,
ref = $bindable(null),
size = 'default',
type = 'button',
variant = 'default',
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ size, variant }), className)}
href={disabled ? undefined : resolve(href as Pathname)}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ size, variant }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
//
Root as Button,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
type ButtonProps as Props,
Root
};
@@ -0,0 +1,79 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import { DateFormatter, type DateValue, getLocalTimeZone } from '@internationalized/date';
import type Calendar from './calendar.svelte';
import CalendarMonthSelect from './calendar-month-select.svelte';
import CalendarYearSelect from './calendar-year-select.svelte';
let {
captionLayout,
locale,
month,
monthFormat,
monthIndex = 0,
months,
placeholder = $bindable(),
yearFormat,
years
}: {
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
locale: string;
month: DateValue;
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
monthIndex: number;
months: ComponentProps<typeof CalendarMonthSelect>['months'];
placeholder: DateValue | undefined;
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
years: ComponentProps<typeof CalendarYearSelect>['years'];
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === 'dropdown'}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === 'dropdown-months'}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === 'dropdown-years'}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-(--cell-radius) [&:last-child[data-selected]_[data-bits-day]]:rounded-e-(--cell-radius)',
className
)}
{...restProps}
/>
@@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
'flex size-(--cell-size) flex-col items-center justify-center gap-1 rounded-(--cell-radius) p-0 leading-none font-normal whitespace-nowrap select-none',
'[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
'not-data-selected:hover:bg-accent/50 not-data-selected:hover:text-accent-foreground',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:text-foreground',
// Outside months
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
// focus
'focus:border-ring focus:ring-ring/50 focus:relative',
// inner spans
'[&>span]:text-xs [&>span]:opacity-70',
className
)}
{...restProps}
/>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Calendar as CalendarPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

Some files were not shown because too many files have changed in this diff Show More