initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Drizzle
|
||||
DATABASE_URL=file:local.db
|
||||
+28
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
/drizzle/
|
||||
+16
@@ -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"
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"eslint.format.enable": true
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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: {}
|
||||
}
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+23
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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>()
|
||||
]
|
||||
});
|
||||
@@ -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(), '')
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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
Reference in New Issue
Block a user