diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e8b671 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +node_modules +**/node_modules +**/dist +data +docs +mockups +tools +.github +*.md +!README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b20c6c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + - run: go build ./... + - run: go vet ./... + - run: go test ./... + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ce78f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# deps / builds +node_modules/ +apps/frontend/dist/ +packages/api-client/dist/ +packages/api-client/src/schema.d.ts +apps/backend/internal/web/dist/* +!apps/backend/internal/web/dist/.keep +!apps/backend/internal/web/dist/index.html + +# data / runtime +data/* +!data/.keep +*.db +*.db-* + +# env / secrets +.env +.env.* + +# misc +.DS_Store +coverage/ + +# local tooling artifacts +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2904e03 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Dash + +Self-hosted homelab dashboard. Single Go binary serving an embedded React UI. +No auth, no YAML. See `ROADMAP.md` (canonical) and `docs/specs/` for design. + +## Layout + +- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui +- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite` +- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract +- `infra/` — Dockerfile (multi-stage, one image) +- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually) +- `data/` — runtime SQLite + uploaded icons (gitignored) + +## Commands + +```sh +npm install +npm run generate # regenerate TS client from openapi.yaml +npm run dev # vite dev server (proxies /api -> :8080) +npm run typecheck # tsc --noEmit +npm run lint # oxlint + anti-slop rules +npm run build # api-client types + frontend bundle + +cd apps/backend +go build ./... # build backend +go test ./... # tests +DASH_DEV=1 go run ./cmd/dash # dev server on :8080, no embedded UI needed +``` + +## Rules + +- OpenAPI is the API contract. Never hand-write API types in the frontend. +- All schema changes go through goose migrations in `apps/backend/internal/db/migrations`. +- Board renderers share state/dnd/popover primitives; never reimplement them per renderer. +- No emojis in code or UI. +- Before pushing: `npm run typecheck`, `npm run lint`, `npm run build`, `go build/vet/test ./...` all green. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c19f5c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TDvorak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aefdcb --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Dash + +A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design. +Single container, UI-managed, no YAML, no auth. + +**Status:** pre-release. See [ROADMAP.md](ROADMAP.md). + +## Run + +```sh +docker run -v ./data:/data -p 3000:3000 dash +``` + +or + +```sh +docker compose up +``` + +Then open http://localhost:3000. + +## Develop + +Requires Go 1.24+ and Node 20+. + +```sh +npm install # workspaces: frontend + api-client +npm run generate # regenerate TS client from openapi.yaml +npm run dev # vite dev on :5173, proxies /api to :8080 +``` + +In another shell: + +```sh +cd apps/backend +DASH_DEV=1 go run ./cmd/dash # API on :8080 (DASH_DEV skips embedded UI) +``` + +## Stack + +Go + Gin + SQLite (single static binary, embeds the frontend) · React 18 + +Vite + TS strict + Tailwind + shadcn/ui + dnd-kit · OpenAPI-generated client. + +## License + +[MIT](LICENSE) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..42d5648 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,142 @@ +# Dash — Roadmap + +A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design. +Single container, UI-managed, no YAML, no auth required. + +Stack: **Go + Gin + SQLite** (single static binary, embeds frontend) · **React 18 + Vite + TS strict + Tailwind + shadcn/ui + dnd-kit** · OpenAPI-generated client. + +--- + +## Design system + +Multiple board styles are a core feature: same system, same functionality, +different UI structure per style. A **renderer** supplies the layout; board +state, drag-drop, popovers, and dialogs are shared primitives underneath. + +| Renderer | Source mockup | Ships | +|---|---|---| +| Bento (default) | `d-bento-mono.html` | v0.1.0 — mixed-size grid, widgets inline | +| Cards | `a-vercel-mono.html` | v0.1.0 — uniform compact grid; validates the seam early | +| Index | `e-editorial.html` | v0.3.0 — serif headers, list rows, stat strip | +| Console | `f-console.html` | v0.3.0 — terminal panels, `[ up ]` tags | + +Contract: `renderers//{BoardView, SectionView, ItemView, WidgetView}`. +Tokens (`--bg`, `--surface`, `--border`, …) shared; dark/light is orthogonal. +User picks renderer in settings; persisted. Renderers never reimplement dnd +or popover logic. + +--- + +## Releases + +| Version | Contents | Gate | +|---|---|---| +| `v0.1.0` | Phase 0 + 1 — working MVP, single image | manual smoke test ✅ | +| `v0.2.0` | Phase 2 — widget layer + first integrations | Pi-hole widget live ✅ | +| `v0.3.0` | Phase 3 — four renderers, ⌘K palette, icon suggest | palette + switcher live ✅ | +| `v1.0.0` | Phase 4 — OSS polish, docs, importers, CI releases | public repo launch | +| `v1.x` | Phase 5 — demand-driven extras only | per-feature | + +--- + +## Phase 0 — Foundation → part of v0.1.0 ✅ done 2026-09-17 + +Repo hygiene and pipelines before features. + +- [x] Monorepo scaffold (`apps/frontend`, `apps/backend`, `packages/api-client`, `infra/`, `data/`) +- [x] MIT `LICENSE`, `README.md`, `.gitignore`, `AGENTS.md` +- [x] `openapi.yaml` stub → generated TS client (`packages/api-client`) +- [x] Backend skeleton: Gin router, `zap` logging, `/api/healthz`, SQLite open + goose migrations runner +- [x] Frontend skeleton: Vite + TS strict + Tailwind + shadcn/ui, theme tokens matching `d-bento-mono.html` CSS vars +- [x] CI (GitHub Actions): `go build ./...`, `go vet ./...`, `go test ./...`, `tsc --noEmit`, `oxlint` + vendored anti-slop rules, `npm run build` +- [x] Multi-stage `Dockerfile` (frontend build → Go build → distroless, `embed.FS` serves UI) +- [x] SPA fallback routing in embedded server (`/` + client-side paths → `index.html`, `/api` isolated) +- [x] `docker-compose.yml` — one service, `./data:/data` volume + +**Exit:** `docker compose up` serves a blank board — verified locally (33.6 MB image). CI green on `main` — pending first push. + +## Phase 1 — MVP → v0.1.0 ✅ done 2026-09-17 + +The product's spine: sections, services, drag-drop, multi-URL. + +- [x] Schema: `sections`, `items`, `urls`, `settings` (fractional `position REAL` ordering) +- [x] REST API: CRUD for sections/items/urls, `reorder` endpoints, icon upload, `settings`, `export`/`import` JSON +- [x] Renderer contract: shared board store + `BoardView`/`SectionView`/`ItemView`/`WidgetView` seam, dnd + popover as shared primitives +- [x] **Bento** renderer (default): mixed-size grid, hairline section headers, tiles with status chips +- [x] **Cards** renderer: uniform compact grid — second renderer proves the seam while code is small +- [x] dnd-kit: reorder items in a section, move items between sections, reorder sections; persist on drop +- [x] Collapse/expand sections (persisted) +- [x] `UrlPopover`: 1 URL → direct open, 2+ → chooser (`local`/`external`/custom tags) +- [x] `ServiceDialog` — one dialog for add + edit (name, URLs+labels, icon URL or file upload) +- [x] Icon handling: upload to `data/icons/`, remote URL passthrough, letter-tile fallback +- [x] Dark/light toggle (persisted), renderer picker in settings, responsive to ~360px +- [x] Pre-paint theme bootstrap in `index.html` (no wrong-theme flash on load) +- [x] Status ping: server-side HEAD request per service URL, cached ~60s → `up`/`down` chip +- [x] Parallel probing: per-item `sync.WaitGroup` fan-out, 4s client timeout +- [x] URL scheme allowlist (`http`/`https` only — `file://` etc. rejected on create + probe) +- [x] Post-drag click suppression (`lastDropAt` window — drops no longer fire tile activation) +- [x] Mobile polish: status chip collapses to dot-only below 620px +- [x] `api_test.go`: reorder positions, URL scheme rejection, settings round-trip + +**Exit:** create sections, add services with icons and dual URLs, drag everything, reload — state persists. `docker run` single image works. Export/import round-trips. — verified: full API + browser smoke (dnd, popover, upload, collapse, themes, both renderers, 360px), `api_test.go` covers reorder/URL-validation/settings. + +## Phase 2 — Widgets → v0.2.0 ✅ done 2026-09-17 + +- [x] Go widget registry: `Widget` interface (`Type()`, `Fetch(cfg)`), `GET /api/widgets/:id/data` with ~30s in-process TTL cache +- [x] `GET /api/widgets/types` → registry metadata drives the add-widget dialog +- [x] `WidgetHost` component: maps `config.type` → React component, polls with backoff +- [x] Widgets as grid items (same `items` table, `kind = 'widget'`, `config JSON`) +- [x] Clock/timezone widget (frontend-only) +- [x] Pi-hole fetcher (blocked %, queries today) — per-day bar series deferred: v5 `api.php` lacks it, needs Pi-hole v6 `/api/history` +- [x] AdGuard Home fetcher (blocked %, queries, avg processing ms) +- [x] Immich fetcher (photo/video count, library size) +- [x] Per-widget config UI (endpoint URL, API key — stored in `config`, never logged) +- [x] `local` type flag: frontend-only widgets (clock) register in `/api/widgets/types` without a fetcher +- [x] Widget tile error/loading states (graceful upstream 502 rendering) +- [x] Widget endpoint tests: type registry, 404 on missing item, 502 on dead upstream, TTL cache hit + +**Exit:** Pi-hole widget shows real data; adding a new integration = one Go file + one React component. — verified: registry, cache, 502 path, generic config dialog, clock live in-browser, Pi-hole fetcher against stubbed v5 API. + +## Phase 3 — Renderers & ergonomics → v0.3.0 ✅ done 2026-09-17 + +- [x] **Index** renderer: serif section headers, full-width rows, masthead stat strip +- [x] **Console** renderer: bordered panels/tables, JetBrains Mono, `[ up ]` tags +- [x] Renderer switcher polished (2×2 radio grid with per-renderer descriptions) +- [x] ⌘K command palette: jump to service, add service, toggle theme, switch renderer (⌘K / Ctrl+K / `/`) +- [x] dashboard-icons auto-suggest in icon picker (name → slug → CDN URL chip + live preview) +- [x] Renderer contract `index` prop → row/section numbering without re-deriving position +- [x] Icon URL live preview in service dialog (dead URLs degrade to dashed tile) +- [x] Palette `/` shortcut + header search button for discoverability +- [x] Fonts: Instrument Serif + JetBrains Mono via fontsource (bundled, no CDN dep) +- [x] Fix: global `:focus-visible` moved into `@layer base` (was silently overriding all utility overrides) +- [ ] User services list → prioritized widget backlog (owner-supplied list pending) + +**Exit:** all four renderers work off the same board state; palette navigates everything. — verified: both new renderers render/persist/dnd/collapse, palette filters and executes, icon suggest resolves real CDN assets, 360px + light theme, docker image 43.9 MB. + +## Phase 4 — Open-source launch → v1.0.0 + +- [ ] Docs: install, configuration, widget development guide (`docs/`) +- [ ] Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml` → sections/items/urls +- [ ] `README.md` hero: screenshots (4 themes), demo GIF, `docker run` one-liner +- [ ] goreleaser → binaries; GHCR image on tag; semver + `CHANGELOG.md` +- [ ] `CONTRIBUTING.md`, issue/PR templates, code of conduct, security policy +- [ ] i18n scaffolding (en default) +- [ ] Public repo flip + announcement assets + +**Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes. + +## Phase 5 — Post-1.0 (demand-driven only) + +Build only what users actually ask for. Candidates, unordered: + +- Optional auth (local password + OIDC) — most-requested, likely first +- Health-check history + uptime graphs +- Custom CSS / accent color / wallpaper +- Iframe/embed widgets, API-status widgets (generic JSON path) +- PWA install, mobile layout polish +- Multiple boards + +## Non-goals (v1) + +Auth, Postgres/external DB, multi-user, mobile app, container lifecycle +management, YAML-as-source-of-truth. Revisit only with evidence of demand. diff --git a/apps/backend/cmd/dash/main.go b/apps/backend/cmd/dash/main.go new file mode 100644 index 0000000..dd82d9a --- /dev/null +++ b/apps/backend/cmd/dash/main.go @@ -0,0 +1,77 @@ +// Command dash is the single-binary entrypoint: API + embedded UI. +package main + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/tdvorak/dash/internal/api" + "github.com/tdvorak/dash/internal/db" + "github.com/tdvorak/dash/internal/web" +) + +func main() { + dev := os.Getenv("DASH_DEV") == "1" + + var logger *zap.Logger + if dev { + logger = zap.Must(zap.NewDevelopment()) + } else { + gin.SetMode(gin.ReleaseMode) + logger = zap.Must(zap.NewProduction()) + } + defer func() { _ = logger.Sync() }() + + dataDir := envOr("DASH_DATA_DIR", "./data") + if err := os.MkdirAll(filepath.Join(dataDir, "icons"), 0o755); err != nil { + logger.Fatal("create data dir", zap.Error(err)) + } + + sqlDB, err := db.Open(filepath.Join(dataDir, "dash.db")) + if err != nil { + logger.Fatal("open database", zap.Error(err)) + } + defer func() { _ = sqlDB.Close() }() + + iconsDir := filepath.Join(dataDir, "icons") + router := api.NewRouter(logger, sqlDB, iconsDir) + if err := web.Register(router); err != nil { + logger.Fatal("register embedded UI", zap.Error(err)) + } + + addr := ":" + envOr("DASH_PORT", "8080") + srv := &http.Server{Addr: addr, Handler: router, ReadHeaderTimeout: 10 * time.Second} + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + logger.Info("listening", zap.String("addr", addr), zap.String("data", dataDir)) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Fatal("serve", zap.Error(err)) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown", zap.Error(err)) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/apps/backend/go.mod b/apps/backend/go.mod new file mode 100644 index 0000000..8c8078a --- /dev/null +++ b/apps/backend/go.mod @@ -0,0 +1,53 @@ +module github.com/tdvorak/dash + +go 1.27 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/pressly/goose/v3 v3.28.0 + go.uber.org/zap v1.28.0 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/apps/backend/go.sum b/apps/backend/go.sum new file mode 100644 index 0000000..11d520f --- /dev/null +++ b/apps/backend/go.sum @@ -0,0 +1,146 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc= +github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go new file mode 100644 index 0000000..9fcf991 --- /dev/null +++ b/apps/backend/internal/api/api.go @@ -0,0 +1,158 @@ +// Package api wires the Gin router and /api handlers. +package api + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Server holds handler dependencies: DB, icon dir, status + widget caches. +type Server struct { + db *sql.DB + iconsDir string + status *statusCache + widgets *widgetCache +} + +// NewRouter builds the HTTP handler: zap access log, recovery, /api routes. +func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine { + s := &Server{ + db: db, + iconsDir: iconsDir, + status: newStatusCache(60 * time.Second), + widgets: newWidgetCache(30 * time.Second), + } + + r := gin.New() + r.Use(accessLog(logger), gin.Recovery()) + + v1 := r.Group("/api") + v1.GET("/healthz", s.healthz) + + v1.GET("/sections", s.listSections) + v1.POST("/sections", s.createSection) + v1.PATCH("/sections/:id", s.updateSection) + v1.DELETE("/sections/:id", s.deleteSection) + v1.POST("/sections/reorder", s.reorderSection) + + v1.POST("/items", s.createItem) + v1.PATCH("/items/:id", s.updateItem) + v1.DELETE("/items/:id", s.deleteItem) + v1.POST("/items/reorder", s.reorderItem) + v1.POST("/items/:id/icon", s.uploadIcon) + v1.GET("/icons/:file", s.getIcon) + + v1.GET("/status", s.getStatus) + v1.GET("/widgets/types", s.widgetTypes) + v1.GET("/widgets/:id/data", s.widgetData) + v1.GET("/settings", s.getSettings) + v1.PUT("/settings", s.putSettings) + v1.GET("/export", s.exportBoard) + v1.POST("/import", s.importBoard) + + return r +} + +// ---------- shared types (mirror openapi.yaml) ---------- + +// URL is one launch target of an item (local, external, custom label). +type URL struct { + ID string `json:"id"` + URL string `json:"url"` + Label string `json:"label"` + Position float64 `json:"position"` +} + +// URLInput is a URL as accepted on create/update (id/position assigned). +type URLInput struct { + URL string `json:"url"` + Label string `json:"label"` +} + +// Item is a board cell: a service tile today, a widget in Phase 2. +type Item struct { + ID string `json:"id"` + SectionID string `json:"sectionId"` + Kind string `json:"kind"` + Name string `json:"name"` + Icon string `json:"icon"` + Position float64 `json:"position"` + Config *json.RawMessage `json:"config"` + URLs []URL `json:"urls"` +} + +// Section is a named, collapsible group of items. +type Section struct { + ID string `json:"id"` + Name string `json:"name"` + Position float64 `json:"position"` + Collapsed bool `json:"collapsed"` + Items []Item `json:"items"` +} + +// ---------- helpers ---------- + +func newID(prefix string) string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return prefix + "_" + hex.EncodeToString(b) +} + +// midpoint returns a fractional position between two optional neighbors. +func midpoint(before, after *float64) float64 { + switch { + case before == nil && after == nil: + return 1024 + case before == nil: + return *after - 1024 + case after == nil: + return *before + 1024 + default: + return (*before + *after) / 2 + } +} + +func fail(c *gin.Context, code int, msg string) { + c.AbortWithStatusJSON(code, gin.H{"error": msg}) +} + +// validURL enforces http(s) launch targets — also keeps /api/status safe +// from file:// or internal-scheme abuse. +func validURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + +func notFound(err error) bool { return errors.Is(err, sql.ErrNoRows) } + +// healthz reports liveness and DB reachability. +func (s *Server) healthz(c *gin.Context) { + if err := s.db.PingContext(c.Request.Context()); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// accessLog is gin.Logger backed by zap: one structured line per request. +func accessLog(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + logger.Info("request", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.Duration("latency", time.Since(start)), + ) + } +} diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go new file mode 100644 index 0000000..12576f7 --- /dev/null +++ b/apps/backend/internal/api/api_test.go @@ -0,0 +1,208 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/db" + "go.uber.org/zap" +) + +func testServer(t *testing.T) *httptest.Server { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "dash.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { d.Close() }) + gin.SetMode(gin.TestMode) + return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir())) +} + +func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) { + t.Helper() + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + defer res.Body.Close() + var out map[string]any + _ = json.NewDecoder(res.Body).Decode(&out) + return res, out +} + +func TestSectionItemReorderFlow(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + _, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`) + _, b := do(t, "POST", srv.URL+"/api/sections", `{"name":"B"}`) + secA, secB := a["id"].(string), b["id"].(string) + + // Move B before A: position should go below A's. + res, mv := do(t, "POST", srv.URL+"/api/sections/reorder", `{"id":"`+secB+`","afterId":"`+secA+`"}`) + if res.StatusCode != http.StatusOK { + t.Fatalf("reorder: %v", mv) + } + if mv["position"].(float64) >= a["position"].(float64) { + t.Fatalf("expected B position < A position, got %v vs %v", mv["position"], a["position"]) + } + + // Item with two urls into A. + _, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+secA+`","name":"Svc","urls":[{"url":"http://x.local","label":"local"},{"url":"https://x.dev","label":"external"}]}`) + if res := it["id"]; res == nil { + t.Fatalf("create item: %v", it) + } + + // Move the item across sections. + res, mv = do(t, "POST", srv.URL+"/api/items/reorder", `{"id":"`+it["id"].(string)+`","sectionId":"`+secB+`"}`) + if res.StatusCode != http.StatusOK { + t.Fatalf("item reorder: %v", mv) + } + + // Board reflects the move. + var secs []Section + resp, err := http.Get(srv.URL + "/api/sections") + if err != nil { + t.Fatalf("get sections: %v", err) + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil { + t.Fatalf("decode board: %v", err) + } + if len(secs) != 2 || secs[0].Name != "B" || secs[1].Name != "A" { + t.Fatalf("unexpected section order: %+v", secs) + } + if len(secs[0].Items) != 1 || secs[0].Items[0].Name != "Svc" { + t.Fatalf("item did not move to section B: %+v", secs[0].Items) + } + if len(secs[0].Items[0].URLs) != 2 { + t.Fatalf("expected 2 urls, got %+v", secs[0].Items[0].URLs) + } +} + +func TestRejectsNonHTTPURLs(t *testing.T) { + srv := testServer(t) + defer srv.Close() + _, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`) + res, body := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Bad","urls":[{"url":"file:///etc/passwd","label":"x"}]}`) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for file:// url, got %d: %v", res.StatusCode, body) + } +} + +func TestSettingsRoundTrip(t *testing.T) { + srv := testServer(t) + defer srv.Close() + res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"theme":"light","renderer":"cards"}`) + if res.StatusCode != http.StatusOK { + t.Fatalf("put settings: %d", res.StatusCode) + } + resp, err := http.Get(srv.URL + "/api/settings") + if err != nil { + t.Fatalf("get settings: %v", err) + } + defer resp.Body.Close() + var got map[string]string + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode settings: %v", err) + } + if got["theme"] != "light" || got["renderer"] != "cards" { + t.Fatalf("settings mismatch: %v", got) + } +} + +func TestWidgetEndpoints(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + // Types registry serves all integrations incl. the local clock. + resp, err := http.Get(srv.URL + "/api/widgets/types") + if err != nil { + t.Fatalf("get types: %v", err) + } + var types []struct { + Type string `json:"type"` + Local bool `json:"local"` + } + if err := json.NewDecoder(resp.Body).Decode(&types); err != nil { + t.Fatalf("decode types: %v", err) + } + resp.Body.Close() + got := map[string]bool{} + for _, w := range types { + got[w.Type] = w.Local + } + for _, want := range []string{"clock", "pihole", "adguard", "immich"} { + if _, ok := got[want]; !ok { + t.Fatalf("missing widget type %s in %v", want, got) + } + } + if !got["clock"] { + t.Fatalf("clock must be local, got %v", got) + } + + // Widget data on a service item -> 400; missing -> 404. + _, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`) + _, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Svc"}`) + res, _ := do(t, "GET", srv.URL+"/api/widgets/"+it["id"].(string)+"/data", "") + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("service item data: %d", res.StatusCode) + } + res, _ = do(t, "GET", srv.URL+"/api/widgets/i_missing/data", "") + if res.StatusCode != http.StatusNotFound { + t.Fatalf("missing item: %d", res.StatusCode) + } + + // Widget item with dead upstream -> 502, then cached (second call fast). + _, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"PH","config":{"type":"pihole","endpoint":"http://127.0.0.1:1","token":"x"}}`) + res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "") + if res.StatusCode != http.StatusBadGateway { + t.Fatalf("dead upstream: %d", res.StatusCode) + } + start := time.Now() + res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "") + if res.StatusCode != http.StatusBadGateway || time.Since(start) > time.Second { + t.Fatalf("expected cached 502, got %d in %v", res.StatusCode, time.Since(start)) + } +} + +func TestMidpoint(t *testing.T) { + f := func(v float64) *float64 { return &v } + if got := midpoint(nil, nil); got != 1024 { + t.Fatalf("empty midpoint: %v", got) + } + if got := midpoint(nil, f(1024)); got != 0 { + t.Fatalf("before-only midpoint: %v", got) + } + if got := midpoint(f(1024), nil); got != 2048 { + t.Fatalf("after-only midpoint: %v", got) + } + if got := midpoint(f(0), f(1024)); got != 512 { + t.Fatalf("bounded midpoint: %v", got) + } +} + +func TestValidURL(t *testing.T) { + for _, u := range []string{"http://a.b", "https://a.b:8443/x"} { + if !validURL(u) { + t.Fatalf("expected valid: %s", u) + } + } + for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://", "notaurl", ""} { + if validURL(u) { + t.Fatalf("expected invalid: %s", u) + } + } +} diff --git a/apps/backend/internal/api/board.go b/apps/backend/internal/api/board.go new file mode 100644 index 0000000..416cc28 --- /dev/null +++ b/apps/backend/internal/api/board.go @@ -0,0 +1,236 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + +// board loads the full tree: sections ordered, each with items and urls. +// Three flat queries assembled in memory — the whole board is ~100 rows. +func (s *Server) board() ([]Section, error) { + secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`) + if err != nil { + return nil, err + } + defer secRows.Close() + + sections := []Section{} + byID := map[string]*Section{} + for secRows.Next() { + var sec Section + if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil { + return nil, err + } + sec.Items = []Item{} + sections = append(sections, sec) + } + for i := range sections { + byID[sections[i].ID] = §ions[i] + } + if err := secRows.Err(); err != nil { + return nil, err + } + + itemRows, err := s.db.Query(`SELECT id, section_id, kind, name, icon, position, config FROM items ORDER BY position`) + if err != nil { + return nil, err + } + defer itemRows.Close() + + items := []*Item{} + itemByID := map[string]*Item{} + for itemRows.Next() { + var it Item + var cfg sql.NullString + if err := itemRows.Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg); err != nil { + return nil, err + } + if cfg.Valid { + raw := json.RawMessage(cfg.String) + it.Config = &raw + } + it.URLs = []URL{} + items = append(items, &it) + itemByID[it.ID] = &it + } + if err := itemRows.Err(); err != nil { + return nil, err + } + + urlRows, err := s.db.Query(`SELECT id, item_id, url, label, position FROM urls ORDER BY position`) + if err != nil { + return nil, err + } + defer urlRows.Close() + + for urlRows.Next() { + var u URL + var itemID string + if err := urlRows.Scan(&u.ID, &itemID, &u.URL, &u.Label, &u.Position); err != nil { + return nil, err + } + if it, ok := itemByID[itemID]; ok { + it.URLs = append(it.URLs, u) + } + } + if err := urlRows.Err(); err != nil { + return nil, err + } + + for _, it := range items { + if sec, ok := byID[it.SectionID]; ok { + sec.Items = append(sec.Items, *it) + } + } + return sections, nil +} + +func (s *Server) listSections(c *gin.Context) { + sections, err := s.board() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, sections) +} + +func (s *Server) createSection(c *gin.Context) { + var in struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" { + fail(c, http.StatusBadRequest, "name required") + return + } + var maxPos sql.NullFloat64 + if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}} + if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, sec) +} + +func (s *Server) updateSection(c *gin.Context) { + var in struct { + Name *string `json:"name"` + Collapsed *bool `json:"collapsed"` + } + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.Name != nil { + if _, err := s.db.Exec(`UPDATE sections SET name = ? WHERE id = ?`, *in.Name, c.Param("id")); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Collapsed != nil { + if _, err := s.db.Exec(`UPDATE sections SET collapsed = ? WHERE id = ?`, *in.Collapsed, c.Param("id")); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + var sec Section + err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")). + Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed) + if notFound(err) { + fail(c, http.StatusNotFound, "section not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + sec.Items = []Item{} + c.JSON(http.StatusOK, sec) +} + +func (s *Server) deleteSection(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM sections WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "section not found") + return + } + c.Status(http.StatusNoContent) +} + +func (s *Server) reorderSection(c *gin.Context) { + var in struct { + ID string `json:"id"` + BeforeID *string `json:"beforeId"` + AfterID *string `json:"afterId"` + } + if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" { + fail(c, http.StatusBadRequest, "id required") + return + } + pos, err := s.neighborPosition("sections", in.BeforeID, in.AfterID) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + res, err := s.db.Exec(`UPDATE sections SET position = ? WHERE id = ?`, pos, in.ID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "section not found") + return + } + var sec Section + if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID). + Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + sec.Items = []Item{} + c.JSON(http.StatusOK, sec) +} + +// neighborPosition resolves the new fractional position between two neighbors. +func (s *Server) neighborPosition(table string, beforeID, afterID *string) (float64, error) { + var before, after *float64 + if beforeID != nil { + p, err := s.positionOf(table, *beforeID) + if err != nil { + return 0, err + } + before = &p + } + if afterID != nil { + p, err := s.positionOf(table, *afterID) + if err != nil { + return 0, err + } + after = &p + } + return midpoint(before, after), nil +} + +func (s *Server) positionOf(table, id string) (float64, error) { + var p float64 + // table is an internal constant (sections|items) — never user input. + err := s.db.QueryRow(`SELECT position FROM `+table+` WHERE id = ?`, id).Scan(&p) + return p, err +} + +func ptrOr(v sql.NullFloat64) *float64 { + if v.Valid { + return &v.Float64 + } + return nil +} diff --git a/apps/backend/internal/api/icons.go b/apps/backend/internal/api/icons.go new file mode 100644 index 0000000..b64a59a --- /dev/null +++ b/apps/backend/internal/api/icons.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// Allowed icon extensions — whitelist keeps the upload dir free of HTML/JS. +var iconExts = map[string]struct{}{ + ".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, + ".gif": {}, ".svg": {}, ".ico": {}, ".avif": {}, +} + +// uploadIcon stores a multipart file under iconsDir as and +// points the item's icon field at /api/icons/. +func (s *Server) uploadIcon(c *gin.Context) { + id := c.Param("id") + var exists int + if err := s.db.QueryRow(`SELECT 1 FROM items WHERE id = ?`, id).Scan(&exists); err != nil { + fail(c, http.StatusNotFound, "item not found") + return + } + + fh, err := c.FormFile("file") + if err != nil { + fail(c, http.StatusBadRequest, "file field required") + return + } + if fh.Size > 4<<20 { + fail(c, http.StatusBadRequest, "icon too large (max 4 MB)") + return + } + ext := strings.ToLower(filepath.Ext(fh.Filename)) + if _, ok := iconExts[ext]; !ok { + fail(c, http.StatusBadRequest, "unsupported icon type") + return + } + + name := id + ext + if err := c.SaveUploadedFile(fh, filepath.Join(s.iconsDir, name)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if _, err := s.db.Exec(`UPDATE items SET icon = ? WHERE id = ?`, "/api/icons/"+name, id); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + it, err := s.loadItem(id) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, it) +} + +// getIcon serves a stored icon. filepath.Base strips any traversal before +// the path is pinned inside iconsDir. +func (s *Server) getIcon(c *gin.Context) { + name := filepath.Base(c.Param("file")) + http.ServeFile(c.Writer, c.Request, filepath.Join(s.iconsDir, name)) +} diff --git a/apps/backend/internal/api/items.go b/apps/backend/internal/api/items.go new file mode 100644 index 0000000..7c8bd58 --- /dev/null +++ b/apps/backend/internal/api/items.go @@ -0,0 +1,248 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + +type itemInput struct { + SectionID string `json:"sectionId"` + Kind string `json:"kind"` + Name string `json:"name"` + Icon string `json:"icon"` + Config *json.RawMessage `json:"config"` + URLs []URLInput `json:"urls"` +} + +func (s *Server) createItem(c *gin.Context) { + var in itemInput + if err := c.ShouldBindJSON(&in); err != nil || in.SectionID == "" || in.Name == "" { + fail(c, http.StatusBadRequest, "sectionId and name required") + return + } + kind := in.Kind + if kind == "" { + kind = "service" + } + if kind != "service" && kind != "widget" { + fail(c, http.StatusBadRequest, "kind must be service or widget") + return + } + for _, u := range in.URLs { + if !validURL(u.URL) { + fail(c, http.StatusBadRequest, "invalid url: "+u.URL) + return + } + } + + var maxPos sql.NullFloat64 + err := s.db.QueryRow(`SELECT MAX(position) FROM items WHERE section_id = ?`, in.SectionID).Scan(&maxPos) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + + it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: kind, Name: in.Name, Icon: in.Icon, + Config: in.Config, Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}} + + tx, err := s.db.Begin() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer func() { _ = tx.Rollback() }() + + var cfg any + if in.Config != nil { + cfg = string(*in.Config) + } + if _, err := tx.Exec(`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`, + it.ID, it.SectionID, it.Kind, it.Name, it.Icon, it.Position, cfg); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if err := insertURLs(tx, it.ID, in.URLs, &it.URLs); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if err := tx.Commit(); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusCreated, it) +} + +// insertURLs writes the url list (position = index * 1024) and fills out. +func insertURLs(tx *sql.Tx, itemID string, in []URLInput, out *[]URL) error { + for i, u := range in { + row := URL{ID: newID("u"), URL: u.URL, Label: u.Label, Position: float64(i+1) * 1024} + if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`, + row.ID, itemID, row.URL, row.Label, row.Position); err != nil { + return err + } + *out = append(*out, row) + } + return nil +} + +func (s *Server) updateItem(c *gin.Context) { + var in struct { + Name *string `json:"name"` + Icon *string `json:"icon"` + Config *json.RawMessage `json:"config"` + URLs *[]URLInput `json:"urls"` + } + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + if in.URLs != nil { + for _, u := range *in.URLs { + if !validURL(u.URL) { + fail(c, http.StatusBadRequest, "invalid url: "+u.URL) + return + } + } + } + id := c.Param("id") + + tx, err := s.db.Begin() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer func() { _ = tx.Rollback() }() + + if in.Name != nil { + if _, err := tx.Exec(`UPDATE items SET name = ? WHERE id = ?`, *in.Name, id); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Icon != nil { + if _, err := tx.Exec(`UPDATE items SET icon = ? WHERE id = ?`, *in.Icon, id); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Config != nil { + if _, err := tx.Exec(`UPDATE items SET config = ? WHERE id = ?`, string(*in.Config), id); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if in.URLs != nil { + if _, err := tx.Exec(`DELETE FROM urls WHERE item_id = ?`, id); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + var out []URL + if err := insertURLs(tx, id, *in.URLs, &out); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if err := tx.Commit(); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + + it, err := s.loadItem(id) + if notFound(err) { + fail(c, http.StatusNotFound, "item not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, it) +} + +// loadItem reads one item with its urls. +func (s *Server) loadItem(id string) (Item, error) { + var it Item + var cfg sql.NullString + err := s.db.QueryRow(`SELECT id, section_id, kind, name, icon, position, config FROM items WHERE id = ?`, id). + Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg) + if err != nil { + return it, err + } + if cfg.Valid { + raw := json.RawMessage(cfg.String) + it.Config = &raw + } + it.URLs = []URL{} + rows, err := s.db.Query(`SELECT id, url, label, position FROM urls WHERE item_id = ? ORDER BY position`, id) + if err != nil { + return it, err + } + defer rows.Close() + for rows.Next() { + var u URL + if err := rows.Scan(&u.ID, &u.URL, &u.Label, &u.Position); err != nil { + return it, err + } + it.URLs = append(it.URLs, u) + } + return it, rows.Err() +} + +func (s *Server) deleteItem(c *gin.Context) { + res, err := s.db.Exec(`DELETE FROM items WHERE id = ?`, c.Param("id")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "item not found") + return + } + c.Status(http.StatusNoContent) +} + +func (s *Server) reorderItem(c *gin.Context) { + var in struct { + ID string `json:"id"` + SectionID *string `json:"sectionId"` + BeforeID *string `json:"beforeId"` + AfterID *string `json:"afterId"` + } + if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" { + fail(c, http.StatusBadRequest, "id required") + return + } + pos, err := s.neighborPosition("items", in.BeforeID, in.AfterID) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + + if in.SectionID == nil { + if err := s.db.QueryRow(`SELECT section_id FROM items WHERE id = ?`, in.ID).Scan(&in.SectionID); notFound(err) { + fail(c, http.StatusNotFound, "item not found") + return + } else if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + res, err := s.db.Exec(`UPDATE items SET position = ?, section_id = ? WHERE id = ?`, pos, *in.SectionID, in.ID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + fail(c, http.StatusNotFound, "item not found") + return + } + it, err := s.loadItem(in.ID) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, it) +} diff --git a/apps/backend/internal/api/settings.go b/apps/backend/internal/api/settings.go new file mode 100644 index 0000000..b922030 --- /dev/null +++ b/apps/backend/internal/api/settings.go @@ -0,0 +1,68 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + +// allSettings reads the whole settings table into a key/value map. +func (s *Server) allSettings() (map[string]json.RawMessage, error) { + rows, err := s.db.Query(`SELECT key, value FROM settings`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]json.RawMessage{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + out[k] = json.RawMessage(v) + } + return out, rows.Err() +} + +func (s *Server) getSettings(c *gin.Context) { + m, err := s.allSettings() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, m) +} + +// putSettings merges the posted keys into the settings table. +func (s *Server) putSettings(c *gin.Context) { + var in map[string]json.RawMessage + if err := c.ShouldBindJSON(&in); err != nil { + fail(c, http.StatusBadRequest, "invalid body") + return + } + tx, err := s.db.Begin() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer func() { _ = tx.Rollback() }() + for k, v := range in { + if _, err := tx.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if err := tx.Commit(); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + m, err := s.allSettings() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, m) +} diff --git a/apps/backend/internal/api/status.go b/apps/backend/internal/api/status.go new file mode 100644 index 0000000..bda6702 --- /dev/null +++ b/apps/backend/internal/api/status.go @@ -0,0 +1,113 @@ +package api + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// statusEntry is one cached reachability probe result. +type statusEntry struct { + Status string `json:"status"` // "up" | "down" + LatencyMs int64 `json:"latencyMs"` + CheckedAt time.Time `json:"checkedAt"` +} + +// statusCache is a TTL map of url -> probe result. Pings are HEAD requests; +// any HTTP response < 500 means the host answered, so the service is up. +// ponytail: pings the primary URL only — extend to all urls if demanded. +type statusCache struct { + mu sync.Mutex + ttl time.Duration + entries map[string]statusEntry + client *http.Client +} + +func newStatusCache(ttl time.Duration) *statusCache { + return &statusCache{ + ttl: ttl, + entries: map[string]statusEntry{}, + client: &http.Client{Timeout: 4 * time.Second}, + } +} + +// check returns the cached probe if fresh, otherwise pings synchronously. +func (sc *statusCache) check(url string) statusEntry { + sc.mu.Lock() + if e, ok := sc.entries[url]; ok && time.Since(e.CheckedAt) < sc.ttl { + sc.mu.Unlock() + return e + } + sc.mu.Unlock() + + e := sc.ping(url) + + sc.mu.Lock() + sc.entries[url] = e + sc.mu.Unlock() + return e +} + +func (sc *statusCache) ping(url string) statusEntry { + start := time.Now() + e := statusEntry{Status: "down", CheckedAt: start} + + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return e + } + resp, err := sc.client.Do(req) + e.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + return e + } + defer resp.Body.Close() + if resp.StatusCode < 500 { + e.Status = "up" + } + return e +} + +// getStatus probes the primary URL of every service item, in parallel, +// and returns a map keyed by item id. +func (s *Server) getStatus(c *gin.Context) { + rows, err := s.db.Query( + `SELECT i.id, u.url FROM items i + JOIN urls u ON u.item_id = i.id + WHERE i.kind = 'service' + AND u.position = (SELECT MIN(position) FROM urls WHERE item_id = i.id)`) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + type target struct{ id, url string } + var targets []target + for rows.Next() { + var t target + if err := rows.Scan(&t.id, &t.url); err != nil { + rows.Close() + fail(c, http.StatusInternalServerError, err.Error()) + return + } + targets = append(targets, t) + } + rows.Close() + + out := make(map[string]statusEntry, len(targets)) + var mu sync.Mutex + var wg sync.WaitGroup + for _, t := range targets { + wg.Add(1) + go func(t target) { + defer wg.Done() + e := s.status.check(t.url) + mu.Lock() + out[t.id] = e + mu.Unlock() + }(t) + } + wg.Wait() + c.JSON(http.StatusOK, out) +} diff --git a/apps/backend/internal/api/transfer.go b/apps/backend/internal/api/transfer.go new file mode 100644 index 0000000..25da740 --- /dev/null +++ b/apps/backend/internal/api/transfer.go @@ -0,0 +1,121 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Export is the portable backup shape: version + settings + board tree. +type Export struct { + Version int `json:"version"` + Settings map[string]json.RawMessage `json:"settings"` + Sections []Section `json:"sections"` +} + +func (s *Server) exportBoard(c *gin.Context) { + settings, err := s.allSettings() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + sections, err := s.board() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections}) +} + +// importBoard replaces board state inside one transaction. Ids from the +// payload are preserved so a round-trip restores identical state. +func (s *Server) importBoard(c *gin.Context) { + var in Export + if err := c.ShouldBindJSON(&in); err != nil || in.Sections == nil { + fail(c, http.StatusBadRequest, "invalid export payload") + return + } + + tx, err := s.db.Begin() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + defer func() { _ = tx.Rollback() }() + + for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} { + if _, err := tx.Exec(q); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + for _, sec := range in.Sections { + if sec.ID == "" || sec.Name == "" { + fail(c, http.StatusBadRequest, "section missing id or name") + return + } + if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`, + sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + for _, it := range sec.Items { + if it.ID == "" || it.Name == "" { + fail(c, http.StatusBadRequest, "item missing id or name") + return + } + var cfg any + if it.Config != nil { + cfg = string(*it.Config) + } + if _, err := tx.Exec( + `INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`, + it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + for _, u := range it.URLs { + if !validURL(u.URL) { + fail(c, http.StatusBadRequest, "invalid url: "+u.URL) + return + } + uid := u.ID + if uid == "" { + uid = newID("u") + } + if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`, + uid, it.ID, u.URL, u.Label, u.Position); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + } + } + for k, v := range in.Settings { + if _, err := tx.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + } + if err := tx.Commit(); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + + sections, err := s.board() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, sections) +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} diff --git a/apps/backend/internal/api/widgets.go b/apps/backend/internal/api/widgets.go new file mode 100644 index 0000000..7b33a30 --- /dev/null +++ b/apps/backend/internal/api/widgets.go @@ -0,0 +1,100 @@ +package api + +import ( + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/tdvorak/dash/internal/widget" +) + +// widgetEntry is one cached fetch result. +type widgetEntry struct { + Data any `json:"data"` + FetchedAt time.Time `json:"fetchedAt"` + err error +} + +// widgetCache is a TTL map of item id -> fetch result. Local widgets never +// reach this — the frontend renders them without a fetch. +type widgetCache struct { + mu sync.Mutex + ttl time.Duration + entries map[string]widgetEntry +} + +func newWidgetCache(ttl time.Duration) *widgetCache { + return &widgetCache{ttl: ttl, entries: map[string]widgetEntry{}} +} + +func (s *Server) widgetTypes(c *gin.Context) { + c.JSON(http.StatusOK, widget.Types()) +} + +// widgetData resolves the item's config.type, fetches via the registry, +// and caches per item for ~30s. Fetch errors become 502 with only the error +// message — config values (API keys) must never leak into a response. +func (s *Server) widgetData(c *gin.Context) { + id := c.Param("id") + + s.widgets.mu.Lock() + if e, ok := s.widgets.entries[id]; ok && time.Since(e.FetchedAt) < s.widgets.ttl { + s.widgets.mu.Unlock() + if e.err != nil { + c.JSON(http.StatusBadGateway, e.Data) + return + } + c.JSON(http.StatusOK, e) + return + } + s.widgets.mu.Unlock() + + it, err := s.loadItem(id) + if notFound(err) { + fail(c, http.StatusNotFound, "item not found") + return + } + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + if it.Kind != "widget" || it.Config == nil { + fail(c, http.StatusBadRequest, "not a widget item") + return + } + var cfg struct { + Type string `json:"type"` + } + if err := json.Unmarshal(*it.Config, &cfg); err != nil || cfg.Type == "" { + fail(c, http.StatusBadRequest, "widget config missing type") + return + } + f, ok := widget.Get(cfg.Type) + if !ok { + fail(c, http.StatusBadRequest, "unknown widget type: "+cfg.Type) + return + } + + data, err := f.Fetch(c.Request.Context(), *it.Config) + e := widgetEntry{FetchedAt: time.Now()} + if err != nil { + // Cache failures briefly too — a dead upstream should not turn + // every poll into a live request. + e.Data = gin.H{"error": err.Error()} + e.err = err + } else { + e.Data = data + } + + s.widgets.mu.Lock() + s.widgets.entries[id] = e + s.widgets.mu.Unlock() + + if e.err != nil { + c.JSON(http.StatusBadGateway, e.Data) + return + } + c.JSON(http.StatusOK, e) +} diff --git a/apps/backend/internal/db/db.go b/apps/backend/internal/db/db.go new file mode 100644 index 0000000..ab2c143 --- /dev/null +++ b/apps/backend/internal/db/db.go @@ -0,0 +1,33 @@ +// Package db opens the SQLite database and applies goose migrations. +package db + +import ( + "database/sql" + "embed" + "fmt" + + "github.com/pressly/goose/v3" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +// Open opens (creating if needed) the SQLite file at path and migrates it +// to the latest schema. WAL + busy_timeout keep reads snappy under writes. +func Open(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path) + sqlDB, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + goose.SetBaseFS(migrations) + if err := goose.SetDialect("sqlite3"); err != nil { + return nil, fmt.Errorf("goose dialect: %w", err) + } + if err := goose.Up(sqlDB, "migrations"); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return sqlDB, nil +} diff --git a/apps/backend/internal/db/migrations/0001_init.sql b/apps/backend/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..0775a5f --- /dev/null +++ b/apps/backend/internal/db/migrations/0001_init.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Key/value store for user prefs (theme, renderer, ...). Values are JSON. +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value JSON NOT NULL +); + +-- +goose Down +DROP TABLE settings; diff --git a/apps/backend/internal/db/migrations/0002_board.sql b/apps/backend/internal/db/migrations/0002_board.sql new file mode 100644 index 0000000..4a8b779 --- /dev/null +++ b/apps/backend/internal/db/migrations/0002_board.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Board tree: sections > items > urls. position REAL = fractional ordering; +-- a drag writes one row instead of renumbering the set. +CREATE TABLE sections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + position REAL NOT NULL, + collapsed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE items ( + id TEXT PRIMARY KEY, + section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'service' CHECK (kind IN ('service', 'widget')), + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + position REAL NOT NULL, + config JSON, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX items_section_pos ON items(section_id, position); + +CREATE TABLE urls ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + url TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + position REAL NOT NULL +); +CREATE INDEX urls_item_pos ON urls(item_id, position); + +-- +goose Down +DROP TABLE urls; +DROP TABLE items; +DROP TABLE sections; diff --git a/apps/backend/internal/web/dist/.keep b/apps/backend/internal/web/dist/.keep new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/internal/web/dist/index.html b/apps/backend/internal/web/dist/index.html new file mode 100644 index 0000000..8e21f5d --- /dev/null +++ b/apps/backend/internal/web/dist/index.html @@ -0,0 +1,21 @@ + + + + + + Dash + + + + + +
+ + diff --git a/apps/backend/internal/web/web.go b/apps/backend/internal/web/web.go new file mode 100644 index 0000000..78bbccb --- /dev/null +++ b/apps/backend/internal/web/web.go @@ -0,0 +1,41 @@ +// Package web serves the embedded frontend build (SPA with index.html +// fallback). dist/ is populated by the frontend build in Docker/CI; the +// committed .keep keeps //go:embed satisfied on a bare clone. +package web + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" +) + +//go:embed all:dist +var dist embed.FS + +// Register mounts the SPA on the router. Non-/api paths serve static files; +// unknown paths fall back to index.html for client-side routing. +func Register(r *gin.Engine) error { + sub, err := fs.Sub(dist, "dist") + if err != nil { + return err + } + fileServer := http.FileServer(http.FS(sub)) + + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + if len(path) >= 4 && path[:4] == "/api" { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if f, err := sub.Open(path[1:]); err == nil { + _ = f.Close() + fileServer.ServeHTTP(c.Writer, c.Request) + return + } + c.Request.URL.Path = "/" + fileServer.ServeHTTP(c.Writer, c.Request) + }) + return nil +} diff --git a/apps/backend/internal/widget/adguard.go b/apps/backend/internal/widget/adguard.go new file mode 100644 index 0000000..1d0a795 --- /dev/null +++ b/apps/backend/internal/widget/adguard.go @@ -0,0 +1,75 @@ +package widget + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// AdGuard Home REST API: GET /control/stats with HTTP basic auth. +type adguard struct{} + +func init() { register(adguard{}) } + +func (adguard) Meta() Type { + return Type{ + Type: "adguard", + Name: "AdGuard Home", + Description: "Queries and block rate", + Fields: []Field{ + {Key: "endpoint", Label: "Endpoint", Placeholder: "http://adguard.local", Required: true}, + {Key: "user", Label: "Username", Required: true}, + {Key: "pass", Label: "Password", Secret: true, Required: true}, + }, + } +} + +type adguardData struct { + QueriesToday int `json:"queriesToday"` + BlockedToday int `json:"blockedToday"` + BlockedPercent float64 `json:"blockedPercent"` + AvgProcessMs float64 `json:"avgProcessMs"` +} + +func (adguard) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) { + endpoint := strings.TrimRight(cfgStr(cfg, "endpoint"), "/") + user, pass := cfgStr(cfg, "user"), cfgStr(cfg, "pass") + if endpoint == "" || user == "" { + return nil, fmt.Errorf("endpoint and user required") + } + var raw struct { + NumDNSQueries int `json:"num_dns_queries"` + NumBlockedFiltering int `json:"num_blocked_filtering"` + AvgProcessingTimeSec float64 `json:"avg_processing_time_sec"` + } + // AdGuard uses basic auth; encode via the URL userinfo so the helper + // stays header-free — and the secret never touches a log line. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/control/stats", nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(user, pass) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("upstream unreachable") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upstream returned %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + var pct float64 + if raw.NumDNSQueries > 0 { + pct = float64(raw.NumBlockedFiltering) / float64(raw.NumDNSQueries) * 100 + } + return adguardData{ + QueriesToday: raw.NumDNSQueries, + BlockedToday: raw.NumBlockedFiltering, + BlockedPercent: pct, + AvgProcessMs: raw.AvgProcessingTimeSec * 1000, + }, nil +} diff --git a/apps/backend/internal/widget/clock.go b/apps/backend/internal/widget/clock.go new file mode 100644 index 0000000..ebc2646 --- /dev/null +++ b/apps/backend/internal/widget/clock.go @@ -0,0 +1,29 @@ +package widget + +import ( + "context" + "encoding/json" + "fmt" +) + +// Clock renders client-side — it registers only so /api/widgets/types lists +// it and the add-widget dialog can offer it like any other type. +type clock struct{} + +func init() { register(clock{}) } + +func (clock) Meta() Type { + return Type{ + Type: "clock", + Name: "Clock", + Description: "Local time in any timezone", + Local: true, + Fields: []Field{ + {Key: "timezone", Label: "Timezone", Placeholder: "Europe/Prague — blank = local"}, + }, + } +} + +func (clock) Fetch(context.Context, json.RawMessage) (any, error) { + return nil, fmt.Errorf("clock renders client-side") +} diff --git a/apps/backend/internal/widget/immich.go b/apps/backend/internal/widget/immich.go new file mode 100644 index 0000000..5477b5a --- /dev/null +++ b/apps/backend/internal/widget/immich.go @@ -0,0 +1,53 @@ +package widget + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Immich REST API: GET /api/statistics with an x-api-key header. +type immich struct{} + +func init() { register(immich{}) } + +func (immich) Meta() Type { + return Type{ + Type: "immich", + Name: "Immich", + Description: "Library size and photo count", + Fields: []Field{ + {Key: "endpoint", Label: "Endpoint", Placeholder: "http://immich.local:2283", Required: true}, + {Key: "apiKey", Label: "API key", Secret: true, Required: true}, + }, + } +} + +type immichData struct { + Photos int `json:"photos"` + Videos int `json:"videos"` + UsageB int64 `json:"usageBytes"` +} + +func (immich) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) { + endpoint := strings.TrimRight(cfgStr(cfg, "endpoint"), "/") + key := cfgStr(cfg, "apiKey") + if endpoint == "" || key == "" { + return nil, fmt.Errorf("endpoint and apiKey required") + } + var raw struct { + Images int `json:"images"` + Videos int `json:"videos"` + Usage int64 `json:"usage"` + } + if err := getJSON(ctx, endpoint+"/api/statistics", + map[string]string{"x-api-key": key}, &raw); err != nil { + return nil, err + } + return immichData{ + Photos: raw.Images, + Videos: raw.Videos, + UsageB: raw.Usage, + }, nil +} diff --git a/apps/backend/internal/widget/pihole.go b/apps/backend/internal/widget/pihole.go new file mode 100644 index 0000000..42f880e --- /dev/null +++ b/apps/backend/internal/widget/pihole.go @@ -0,0 +1,62 @@ +package widget + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// Pi-hole v5 HTTP API: /admin/api.php?summaryRaw&auth=. +// jarvis: v5 only for now — v6 (/api/...) lands when someone runs it. +type pihole struct{} + +func init() { register(pihole{}) } + +func (pihole) Meta() Type { + return Type{ + Type: "pihole", + Name: "Pi-hole", + Description: "Queries and block rate", + Fields: []Field{ + {Key: "endpoint", Label: "Endpoint", Placeholder: "http://pi.hole", Required: true}, + {Key: "token", Label: "API token", Secret: true, Placeholder: "web password / api token"}, + }, + } +} + +type piholeData struct { + QueriesToday int `json:"queriesToday"` + BlockedToday int `json:"blockedToday"` + BlockedPercent float64 `json:"blockedPercent"` + GravitySize int `json:"gravitySize"` + Status string `json:"status"` +} + +func (pihole) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) { + endpoint := strings.TrimRight(cfgStr(cfg, "endpoint"), "/") + if endpoint == "" { + return nil, fmt.Errorf("endpoint required") + } + u := endpoint + "/admin/api.php?summaryRaw" + if tok := cfgStr(cfg, "token"); tok != "" { + u += "&auth=" + tok + } + var raw struct { + DNSQueriesToday int `json:"dns_queries_today"` + AdsBlockedToday int `json:"ads_blocked_today"` + AdsPercentToday float64 `json:"ads_percentage_today"` + DomainsBlocked int `json:"domains_being_blocked"` + Status string `json:"status"` + } + if err := getJSON(ctx, u, nil, &raw); err != nil { + return nil, err + } + return piholeData{ + QueriesToday: raw.DNSQueriesToday, + BlockedToday: raw.AdsBlockedToday, + BlockedPercent: raw.AdsPercentToday, + GravitySize: raw.DomainsBlocked, + Status: raw.Status, + }, nil +} diff --git a/apps/backend/internal/widget/widget.go b/apps/backend/internal/widget/widget.go new file mode 100644 index 0000000..58518ad --- /dev/null +++ b/apps/backend/internal/widget/widget.go @@ -0,0 +1,95 @@ +// Package widget is the integration registry: each widget type declares its +// config fields and a Fetcher that pulls live data from the target service. +// Adding an integration = one file here + one React component. +package widget + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +// Field describes one config input rendered by the add-widget dialog. +type Field struct { + Key string `json:"key"` + Label string `json:"label"` + Secret bool `json:"secret,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Required bool `json:"required,omitempty"` +} + +// Type is registry metadata for one widget, served at /api/widgets/types. +type Type struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Local bool `json:"local,omitempty"` + Fields []Field `json:"fields"` +} + +// Fetcher pulls live data for one configured widget item. cfg is the item's +// config JSON — never log it, it may hold API keys. +type Fetcher interface { + Meta() Type + Fetch(ctx context.Context, cfg json.RawMessage) (any, error) +} + +var registry = map[string]Fetcher{} + +func register(f Fetcher) { registry[f.Meta().Type] = f } + +// Get resolves a config.type to its fetcher. +func Get(t string) (Fetcher, bool) { + f, ok := registry[t] + return f, ok +} + +// Types lists all registered types, sorted for a stable dialog order. +func Types() []Type { + out := make([]Type, 0, len(registry)) + for _, f := range registry { + out = append(out, f.Meta()) + } + sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type }) + return out +} + +var client = &http.Client{Timeout: 6 * time.Second} + +// getJSON fetches url and decodes the body into out. headers adds auth etc. +// The caller's config is never logged — errors carry only status codes. +func getJSON(ctx context.Context, url string, headers map[string]string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("upstream unreachable") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + return fmt.Errorf("upstream returned %d", resp.StatusCode) + } + return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out) +} + +// cfgStr reads a string field from a config blob. +func cfgStr(cfg json.RawMessage, key string) string { + var m map[string]any + if err := json.Unmarshal(cfg, &m); err != nil { + return "" + } + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 0000000..8bfc737 --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..5ff4c46 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + Dash + + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..b6b6a52 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "@dash/frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@dash/api-client": "*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/geist": "^5.2.5", + "@fontsource-variable/geist-mono": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/instrument-serif": "^5.3.0", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-slot": "^1.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.545.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.13", + "@types/node": "^24.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^5.0.4", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2", + "vite": "^7.1.7" + } +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..3da0f7b --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; +import { BoardProvider, useBoard } from "@/board/store"; +import { Board } from "@/board/Board"; +import { ServiceDialog } from "@/components/ServiceDialog"; +import { WidgetDialog } from "@/components/WidgetDialog"; +import { SettingsDialog } from "@/components/SettingsDialog"; +import { CommandPalette } from "@/components/CommandPalette"; +import { Button } from "@/components/ui/button"; +import type { Item } from "@/types"; + +export default function App() { + return ( + + + + ); +} + +function Shell() { + const board = useBoard(); + const [settingsOpen, setSettingsOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [svcOpen, setSvcOpen] = useState(false); + const [wdgOpen, setWdgOpen] = useState(false); + const [editing, setEditing] = useState(); + + // ⌘K / Ctrl+K opens the palette; / does too when not typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const typing = e.target instanceof HTMLElement + && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable); + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((o) => !o); + } else if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + setPaletteOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + function openEditor(it: Item) { + setEditing(it); + if (it.kind === "widget") setWdgOpen(true); + else setSvcOpen(true); + } + + return ( +
+
+
+ + + + + + + Dash +
+
+ + + + + +
+
+ + + + + + + { setEditing(undefined); setSvcOpen(true); }} + onAddWidget={() => { setEditing(undefined); setWdgOpen(true); }} + onSettings={() => setSettingsOpen(true)} + /> +
+ ); +} diff --git a/apps/frontend/src/api.ts b/apps/frontend/src/api.ts new file mode 100644 index 0000000..c44b610 --- /dev/null +++ b/apps/frontend/src/api.ts @@ -0,0 +1,4 @@ +import { createClient } from "@dash/api-client"; + +/** Same-origin typed client; vite proxies /api in dev. */ +export const api = createClient(); diff --git a/apps/frontend/src/board/Board.tsx b/apps/frontend/src/board/Board.tsx new file mode 100644 index 0000000..fe5c3c1 --- /dev/null +++ b/apps/frontend/src/board/Board.tsx @@ -0,0 +1,121 @@ +import { useState, type FormEvent } from "react"; +import { Plus } from "lucide-react"; +import { useBoard } from "./store"; +import { BoardDnd, ItemSortZone, SectionSortList } from "./dnd"; +import { renderers } from "@/renderers"; +import type { Item } from "@/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +// Board is the renderer-agnostic glue: state + dnd + sort contexts wired once, +// the active renderer supplies the chrome. +export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) { + const board = useBoard(); + const views = renderers[board.renderer]; + const [addingSection, setAddingSection] = useState(false); + const [sectionName, setSectionName] = useState(""); + + async function submitSection(e: FormEvent) { + e.preventDefault(); + const name = sectionName.trim(); + if (!name) return; + await board.addSection(name); + setSectionName(""); + setAddingSection(false); + } + + if (!board.loaded) { + return
; + } + + if (board.sections.length === 0) { + return ( +
+

No sections yet.

+ +
+ ); + } + + return ( +
+ + s.id)}> + + {board.sections.map((sec, si) => ( + void board.patchSection(sec.id, { collapsed: !sec.collapsed })} + onDelete={() => { + if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) { + void board.deleteSection(sec.id); + } + }} + > + + {sec.items.map((it, ii) => + it.kind === "widget" ? ( + onEditItem(it)} /> + ) : ( + onEditItem(it)} + /> + ), + )} + + + ))} + + + + +
+ +
+
+ ); +} + +// Inline add-section row — one field doesn't warrant a dialog. +function AddSectionRow({ + open, setOpen, name, setName, onSubmit, +}: { + open: boolean; setOpen: (o: boolean) => void; + name: string; setName: (n: string) => void; + onSubmit: (e: FormEvent) => void; +}) { + if (!open) { + return ( + + ); + } + return ( +
+ setName(e.target.value)} + placeholder="Section name" + className="w-[200px]" + onBlur={() => !name.trim() && setOpen(false)} + onKeyDown={(e) => e.key === "Escape" && setOpen(false)} + /> + +
+ ); +} diff --git a/apps/frontend/src/board/dnd.tsx b/apps/frontend/src/board/dnd.tsx new file mode 100644 index 0000000..7022cc8 --- /dev/null +++ b/apps/frontend/src/board/dnd.tsx @@ -0,0 +1,251 @@ +import { + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + rectIntersection, + useDroppable, + useSensor, + useSensors, + type CollisionDetection, + type DraggableAttributes, + type DraggableSyntheticListeners, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { Section } from "@/types"; +import { useBoard } from "./store"; + +// Shared drag-drop primitives. Renderers consume the hooks; they never +// wire sensors, contexts, or persistence themselves. + +interface DragData { + type: "section" | "item" | "sectionBody"; + sectionId?: string; +} + +/** dnd-kit types data.current as any; our hooks below only ever attach DragData. */ +function dragData(d: { data: { current: unknown } }): DragData | undefined { + // SAFETY: payloads are set exclusively by useItemDnd/useSectionDnd/ItemSortZone. + return d.data.current as DragData | undefined; +} + +const collision: CollisionDetection = (args) => { + const p = pointerWithin(args); + return p.length > 0 ? p : rectIntersection(args); +}; + +// Pointerup fires a click on whatever sits under the cursor, which would open +// a tile's URL popover right after a drop. Stamp the last real drag and let +// ItemAnchor swallow clicks that arrive immediately after it. +let lastDropAt = 0; +export function clickIsDragTail(): boolean { + return performance.now() - lastDropAt < 200; +} + +/** Wraps the whole board: sensors, drag lifecycle, overlay, persistence. */ +export function BoardDnd({ children }: { children: ReactNode }) { + const board = useBoard(); + const [activeLabel, setActiveLabel] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ); + + function onDragStart(e: DragStartEvent) { + const data = dragData(e.active); + if (data?.type === "item") { + const item = board.sections + .flatMap((s) => s.items) + .find((i) => i.id === e.active.id); + setActiveLabel(item?.name ?? null); + } else { + const sec = board.sections.find((s) => s.id === e.active.id); + setActiveLabel(sec?.name ?? null); + } + } + + function onDragOver(e: DragOverEvent) { + const active = dragData(e.active); + const over = e.over ? dragData(e.over) : undefined; + if (!active || active.type !== "item" || !over || !e.over) return; + + const activeId = String(e.active.id); + if (over.type === "item" && over.sectionId && over.sectionId !== active.sectionId) { + const items = board.sections.find((s) => s.id === over.sectionId)?.items ?? []; + const idx = items.findIndex((i) => i.id === String(e.over?.id)); + board.moveItemLocal(activeId, over.sectionId, idx < 0 ? items.length : idx); + } else if (over.type === "sectionBody" && over.sectionId && over.sectionId !== active.sectionId) { + const len = board.sections.find((s) => s.id === over.sectionId)?.items.length ?? 0; + board.moveItemLocal(activeId, over.sectionId, len); + } else if (over.type === "section" && over.sectionId !== active.sectionId) { + const len = board.sections.find((s) => s.id === String(e.over?.id))?.items.length ?? 0; + board.moveItemLocal(activeId, String(e.over?.id), len); + } + } + + function onDragEnd(e: DragEndEvent) { + setActiveLabel(null); + lastDropAt = performance.now(); + const active = dragData(e.active); + const overId = e.over ? String(e.over.id) : null; + if (!active || !overId) return; + + if (active.type === "section") { + const over = e.over ? dragData(e.over) : undefined; + // Dropping anywhere inside another section — its body or an item — + // counts as reordering onto that section. + const targetId = + over?.type === "section" ? overId : + over?.sectionId ?? null; + if (!targetId) return; + const ids = board.sections.map((s) => s.id); + const from = ids.indexOf(String(e.active.id)); + const to = ids.indexOf(targetId); + if (from < 0 || to < 0 || from === to) return; + const next = arrayMove(board.sections, from, to); + board.moveSectionLocal(String(e.active.id), to); + const idx = next.findIndex((s) => s.id === e.active.id); + void board.persistSectionMove( + String(e.active.id), + idx > 0 ? next[idx - 1]?.id : undefined, + idx < next.length - 1 ? next[idx + 1]?.id : undefined, + ); + return; + } + + if (active.type === "item") { + const id = String(e.active.id); + const over = e.over ? dragData(e.over) : undefined; + const sec = board.sections.find((s) => s.items.some((i) => i.id === id)); + if (!sec) return; + + // onDragOver only mutates local state for cross-section moves; for a + // same-section drop the order is still pre-drag, so apply it now. + let items = sec.items; + const from = items.findIndex((i) => i.id === id); + if (over?.type === "item" && over.sectionId === sec.id) { + const to = items.findIndex((i) => i.id === overId); + if (to >= 0 && to !== from) { + items = arrayMove(items, from, to); + board.moveItemLocal(id, sec.id, to); + } + } else if (over?.type === "sectionBody" && over.sectionId === sec.id) { + items = arrayMove(items, from, items.length - 1); + board.moveItemLocal(id, sec.id, items.length - 1); + } + + const idx = items.findIndex((i) => i.id === id); + void board.persistItemMove( + id, + sec.id, + idx > 0 ? items[idx - 1]?.id : undefined, + idx < items.length - 1 ? items[idx + 1]?.id : undefined, + ); + } + } + + function onDragCancel() { + setActiveLabel(null); + void board.refresh(); + } + + return ( + + {children} + + {activeLabel ? ( +
+ {activeLabel} +
+ ) : null} +
+
+ ); +} + +/** Sortable context for the section list (vertical). Shared, not per-renderer. */ +export function SectionSortList({ ids, children }: { ids: string[]; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Sortable context + droppable body for one section's items. + The div carries the renderer's grid classes so the drop zone has a real box. */ +export function ItemSortZone({ section, className, children }: { section: Section; className?: string; children: ReactNode }) { + const { setNodeRef } = useDroppable({ + id: `body:${section.id}`, + data: { type: "sectionBody", sectionId: section.id } satisfies DragData, + }); + return ( + i.id)} strategy={rectSortingStrategy}> +
+ {children} +
+
+ ); +} + +export interface SortableBind { + ref: (el: HTMLElement | null) => void; + style: CSSProperties; + isDragging: boolean; + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; +} + +/** Item-level sortable binding. ItemView applies ref/style and spreads + attributes/listeners on the tile (whole-tile drag; clicks pass through + because the pointer sensor requires 6px of movement). */ +export function useItemDnd(sectionId: string, itemId: string): SortableBind { + const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ + id: itemId, + data: { type: "item", sectionId } satisfies DragData, + }); + return { + ref: setNodeRef, + style: { transform: CSS.Transform.toString(transform), transition }, + isDragging, + attributes, + listeners, + }; +} + +/** Section-level sortable binding. Handle props go on the grip button. */ +export function useSectionDnd(sectionId: string): SortableBind { + const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ + id: sectionId, + data: { type: "section", sectionId } satisfies DragData, + }); + return { + ref: setNodeRef, + style: { transform: CSS.Transform.toString(transform), transition }, + isDragging, + attributes, + listeners, + }; +} diff --git a/apps/frontend/src/board/primitives.tsx b/apps/frontend/src/board/primitives.tsx new file mode 100644 index 0000000..ce56246 --- /dev/null +++ b/apps/frontend/src/board/primitives.tsx @@ -0,0 +1,116 @@ +import { useState, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import type { Item, Status } from "@/types"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { clickIsDragTail } from "@/board/dnd"; + +/** host extracts the display host from a service URL. */ +export function hostOf(url: string): string { + try { + const u = new URL(url); + return u.host + (u.pathname !== "/" ? u.pathname.replace(/\/$/, "") : ""); + } catch { + return url; + } +} + +/** IconImg renders the item icon or a letter tile fallback. */ +export function IconImg({ item, size = 40, className }: { item: Item; size?: number; className?: string }) { + const [failed, setFailed] = useState(false); + const letter = item.name.trim().charAt(0).toUpperCase() || "?"; + return ( +
+ {!item.icon || failed ? ( + letter + ) : ( + setFailed(true)} + loading="lazy" + /> + )} +
+ ); +} + +/** StatusChip — the only color on the board. Green up, amber down, faint when + unknown (no URLs or status not yet probed). */ +export function StatusChip({ status, variant = "chip" }: { status?: Status | undefined; variant?: "chip" | "dot" }) { + const state = status?.status; + if (variant === "dot") { + return ( + + ); + } + return ( + + + {state ?? "…"} + + ); +} + +/** ItemAnchor gives a tile its launch behavior: one URL opens directly, + multiple URLs open a label-tagged chooser popover, zero URLs open edit. */ +export function ItemAnchor({ + item, + onEdit, + children, +}: { + item: Item; + onEdit: () => void; + children: (open: () => void) => ReactNode; +}) { + const [open, setOpen] = useState(false); + const urls = item.urls; + + const activate = () => { + if (clickIsDragTail()) return; + if (urls.length === 0) onEdit(); + else if (urls.length === 1 && urls[0]) window.open(urls[0].url, "_blank", "noopener"); + else setOpen(true); + }; + + if (urls.length < 2) return <>{children(activate)}; + + return ( + + {children(activate)} + e.preventDefault()}> + {urls.map((u) => ( + setOpen(false)} + className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] text-text transition-colors hover:bg-surface-hover" + > + {u.label ? ( + + {u.label} + + ) : null} + {hostOf(u.url)} + + ))} + + + ); +} diff --git a/apps/frontend/src/board/store.tsx b/apps/frontend/src/board/store.tsx new file mode 100644 index 0000000..f4a3de2 --- /dev/null +++ b/apps/frontend/src/board/store.tsx @@ -0,0 +1,226 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { api } from "@/api"; +import type { + Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme, +} from "@/types"; +import { parsePrefs } from "@/types"; + +// Board store — single renderer-agnostic source of truth for the board tree, +// reachability statuses, and persisted settings. Renderers consume via useBoard. + +interface BoardState { + sections: Section[]; + statuses: StatusMap; + theme: Theme; + renderer: RendererName; + loaded: boolean; + refresh(): Promise; + addSection(name: string): Promise
; + patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise; + deleteSection(id: string): Promise; + addItem(input: ItemInput): Promise; + patchItem(id: string, patch: ItemPatch): Promise; + deleteItem(id: string): Promise; + uploadIcon(id: string, file: File): Promise; + moveItemLocal(itemId: string, toSectionId: string, index: number): void; + moveSectionLocal(id: string, index: number): void; + persistItemMove(itemId: string, sectionId: string, beforeId?: string, afterId?: string): Promise; + persistSectionMove(id: string, beforeId?: string, afterId?: string): Promise; + setSetting(key: keyof Prefs, value: string): Promise; +} + +const Ctx = createContext(null); + +export function BoardProvider({ children }: { children: ReactNode }) { + const [sections, setSections] = useState([]); + const [statuses, setStatuses] = useState({}); + const [prefs, setPrefs] = useState({ theme: "dark", renderer: "bento" }); + const [loaded, setLoaded] = useState(false); + + const refresh = useCallback(async () => { + const { data } = await api.GET("/api/sections"); + if (data) setSections(data); + }, []); + + const refreshStatus = useCallback(async () => { + const { data } = await api.GET("/api/status"); + if (data) setStatuses(data); + }, []); + + useEffect(() => { + void (async () => { + const [{ data: secs }, { data: cfg }] = await Promise.all([ + api.GET("/api/sections"), + api.GET("/api/settings"), + ]); + if (secs) setSections(secs); + if (cfg) setPrefs(parsePrefs(cfg)); + setLoaded(true); + void refreshStatus(); + })(); + const t = setInterval(() => void refreshStatus(), 60_000); + return () => clearInterval(t); + }, [refreshStatus]); + + const { theme, renderer } = prefs; + + // Apply theme: drives all CSS vars. localStorage mirrors + // the server setting so index.html can pre-paint without a flash. + useEffect(() => { + if (!loaded) return; + document.documentElement.dataset.theme = theme; + localStorage.setItem("dash.theme", theme); + }, [theme, loaded]); + + const addSection = useCallback(async (name: string) => { + const { data } = await api.POST("/api/sections", { body: { name } }); + if (data) setSections((s) => [...s, data]); + return data ?? null; + }, []); + + const patchSection = useCallback(async (id: string, patch: { name?: string; collapsed?: boolean }) => { + setSections((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s))); + const { error } = await api.PATCH("/api/sections/{id}", { params: { path: { id } }, body: patch }); + if (error) await refresh(); + }, [refresh]); + + const deleteSection = useCallback(async (id: string) => { + setSections((ss) => ss.filter((s) => s.id !== id)); + const { error } = await api.DELETE("/api/sections/{id}", { params: { path: { id } } }); + if (error) await refresh(); + }, [refresh]); + + const addItem = useCallback(async (input: ItemInput) => { + const { data } = await api.POST("/api/items", { body: input }); + if (data) { + setSections((ss) => ss.map((s) => (s.id === data.sectionId ? { ...s, items: [...s.items, data] } : s))); + void refreshStatus(); + } + return data ?? null; + }, [refreshStatus]); + + const patchItem = useCallback(async (id: string, patch: ItemPatch) => { + const { data } = await api.PATCH("/api/items/{id}", { params: { path: { id } }, body: patch }); + if (data) { + setSections((ss) => ss.map((s) => ({ + ...s, + items: s.items.map((i) => (i.id === id ? data : i)), + }))); + void refreshStatus(); + } + }, [refreshStatus]); + + const deleteItem = useCallback(async (id: string) => { + setSections((ss) => ss.map((s) => ({ ...s, items: s.items.filter((i) => i.id !== id) }))); + const { error } = await api.DELETE("/api/items/{id}", { params: { path: { id } } }); + if (error) await refresh(); + }, [refresh]); + + const uploadIcon = useCallback(async (id: string, file: File) => { + const form = new FormData(); + form.append("file", file); + const res = await fetch(`/api/items/${id}/icon`, { method: "POST", body: form }); + if (res.ok) { + // SAFETY: /api/items/:id/icon returns Item per openapi.yaml; fetch isn't typed by the client. + const it = (await res.json()) as Item; + setSections((ss) => ss.map((s) => ({ + ...s, + items: s.items.map((i) => (i.id === id ? it : i)), + }))); + } + }, []); + + // Local moves run during drag (optimistic); persist* fire the API on drop. + const moveItemLocal = useCallback((itemId: string, toSectionId: string, index: number) => { + setSections((ss) => { + const next = ss.map((s) => ({ ...s, items: [...s.items] })); + let moved: Item | undefined; + for (const s of next) { + const i = s.items.findIndex((it) => it.id === itemId); + if (i >= 0) { + [moved] = s.items.splice(i, 1); + break; + } + } + if (!moved) return ss; + moved = { ...moved, sectionId: toSectionId }; + const target = next.find((s) => s.id === toSectionId); + if (!target) return ss; + target.items.splice(Math.min(index, target.items.length), 0, moved); + return next; + }); + }, []); + + const moveSectionLocal = useCallback((id: string, index: number) => { + setSections((ss) => { + const next = [...ss]; + const from = next.findIndex((s) => s.id === id); + if (from < 0) return ss; + const [moved] = next.splice(from, 1); + if (!moved) return ss; + next.splice(Math.min(index, next.length), 0, moved); + return next; + }); + }, []); + + const persistItemMove = useCallback(async (itemId: string, sectionId: string, beforeId?: string, afterId?: string) => { + const { error } = await api.POST("/api/items/reorder", { + body: { + id: itemId, sectionId, + ...(beforeId ? { beforeId } : {}), + ...(afterId ? { afterId } : {}), + }, + }); + if (error) await refresh(); + }, [refresh]); + + const persistSectionMove = useCallback(async (id: string, beforeId?: string, afterId?: string) => { + const { error } = await api.POST("/api/sections/reorder", { + body: { + id, + ...(beforeId ? { beforeId } : {}), + ...(afterId ? { afterId } : {}), + }, + }); + if (error) await refresh(); + }, [refresh]); + + const setSetting = useCallback(async (key: keyof Prefs, value: string) => { + setPrefs((p) => ({ ...p, [key]: value })); + const { data, error } = await api.PUT("/api/settings", { + body: { [key]: value }, + }); + if (error) await refresh(); + else if (data) setPrefs(parsePrefs(data)); + }, [refresh]); + + const value = useMemo(() => ({ + sections, statuses, theme, renderer, loaded, + refresh, addSection, patchSection, deleteSection, + addItem, patchItem, deleteItem, uploadIcon, + moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove, + setSetting, + }), [ + sections, statuses, theme, renderer, loaded, + refresh, addSection, patchSection, deleteSection, + addItem, patchItem, deleteItem, uploadIcon, + moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove, + setSetting, + ]); + + return {children}; +} + +export function useBoard(): BoardState { + const v = useContext(Ctx); + if (!v) throw new Error("useBoard outside BoardProvider"); + return v; +} diff --git a/apps/frontend/src/components/CommandPalette.tsx b/apps/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..3418699 --- /dev/null +++ b/apps/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowRight, Download, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react"; +import { useBoard } from "@/board/store"; +import { hostOf, IconImg } from "@/board/primitives"; +import { rendererLabels, rendererList } from "@/renderers"; +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import type { Item } from "@/types"; + +// ⌘K palette — flat filtered list over actions + service endpoints. +// No cmdk dep: an input, a list, and an active-index is the whole mechanic. + +interface Command { + id: string; + group: "Actions" | "Services"; + icon?: Item; + glyph?: typeof LayoutGrid; + label: string; + hint?: string | undefined; + run(): void; +} + +export function CommandPalette({ + open, + onOpenChange, + onAddService, + onAddWidget, + onSettings, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + onAddService(): void; + onAddWidget(): void; + onSettings(): void; +}) { + const board = useBoard(); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const listRef = useRef(null); + + const commands = useMemo(() => { + const cmds: Command[] = [ + { id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService }, + { id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget }, + { id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings }, + { + id: "theme", group: "Actions", glyph: board.theme === "dark" ? Sun : Moon, + label: `Switch to ${board.theme === "dark" ? "light" : "dark"} theme`, + run: () => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark"), + }, + ...rendererList.map((r): Command => ({ + id: `renderer-${r}`, group: "Actions", glyph: LayoutGrid, + label: `Renderer: ${rendererLabels[r]}`, + hint: board.renderer === r ? "active" : undefined, + run: () => void board.setSetting("renderer", r), + })), + { + id: "export", group: "Actions", glyph: Download, label: "Export board JSON", + run: () => { window.open("/api/export", "_blank", "noopener"); }, + }, + ]; + for (const sec of board.sections) { + for (const it of sec.items) { + if (it.kind === "widget") continue; + for (const u of it.urls) { + cmds.push({ + id: `svc-${u.id}`, group: "Services", icon: it, + label: u.label ? `${it.name} — ${u.label}` : it.name, + hint: hostOf(u.url), + run: () => window.open(u.url, "_blank", "noopener"), + }); + } + } + } + return cmds; + }, [board.sections, board.theme, board.renderer, board.setSetting, onAddService, onAddWidget, onSettings]); + + const q = query.trim().toLowerCase(); + const filtered = q + ? commands.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(q)) + : commands; + + const clamped = Math.min(active, Math.max(0, filtered.length - 1)); + + useEffect(() => { + if (open) { setQuery(""); setActive(0); } + }, [open]); + + useEffect(() => { + listRef.current?.querySelector(`[data-idx="${clamped}"]`)?.scrollIntoView({ block: "nearest" }); + }, [clamped]); + + function pick(i: number) { + const c = filtered[i]; + if (!c) return; + onOpenChange(false); + c.run(); + } + + const groups: { name: string; items: { c: Command; i: number }[] }[] = []; + filtered.forEach((c, i) => { + const g = groups.find((g) => g.name === c.group); + if (g) g.items.push({ c, i }); + else groups.push({ name: c.group, items: [{ c, i }] }); + }); + + return ( + + + Command palette +
+ { setQuery(e.target.value); setActive(0); }} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { e.preventDefault(); setActive(clamped + 1); } + else if (e.key === "ArrowUp") { e.preventDefault(); setActive(clamped - 1); } + else if (e.key === "Enter") { e.preventDefault(); pick(clamped); } + }} + placeholder="Search services and actions…" + className="h-12 w-full bg-transparent text-[14px] outline-none placeholder:text-text-faint focus-visible:outline-none" + /> + esc +
+
+ {filtered.length === 0 ? ( +

No matches.

+ ) : groups.map((g) => ( +
+
+ {g.name} +
+ {g.items.map(({ c, i }) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/ServiceDialog.tsx b/apps/frontend/src/components/ServiceDialog.tsx new file mode 100644 index 0000000..300c3ba --- /dev/null +++ b/apps/frontend/src/components/ServiceDialog.tsx @@ -0,0 +1,233 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus, Trash2, Upload, X } from "lucide-react"; +import { useBoard } from "@/board/store"; +import type { Item, UrlInput } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +// dashboard-icons CDN serves png/.png — slugify the service name for +// the one-click suggestion under the icon field. +const DI_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"; +const diSlug = (name: string) => + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + +// One dialog for both add and edit: item present = edit, absent = add. +export function ServiceDialog({ + open, + onOpenChange, + item, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + item?: Item | undefined; +}) { + const board = useBoard(); + const editing = Boolean(item); + + const [name, setName] = useState(""); + const [icon, setIcon] = useState(""); + const [urls, setUrls] = useState([{ url: "", label: "local" }]); + const [sectionId, setSectionId] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + const fileRef = useRef(null); + + useEffect(() => { + if (!open) return; + setErr(""); + setName(item?.name ?? ""); + setIcon(item?.icon ?? ""); + setUrls( + item && item.urls.length > 0 + ? item.urls.map((u) => ({ url: u.url, label: u.label })) + : [{ url: "", label: "local" }], + ); + setSectionId(item?.sectionId ?? board.sections[0]?.id ?? ""); + }, [open, item, board.sections]); + + const setUrl = (i: number, patch: Partial) => + setUrls((us) => us.map((u, j) => (j === i ? { ...u, ...patch } : u))); + + async function submit() { + const cleanUrls = urls.filter((u) => u.url.trim() !== ""); + if (!name.trim()) return setErr("Name is required"), undefined; + if (cleanUrls.some((u) => !/^https?:\/\/.+/.test(u.url))) { + return setErr("URLs must start with http:// or https://"), undefined; + } + setBusy(true); + try { + const file = fileRef.current?.files?.[0]; + if (editing && item) { + await board.patchItem(item.id, { name: name.trim(), icon, urls: cleanUrls }); + if (file) await board.uploadIcon(item.id, file); + } else { + let sid = sectionId; + if (!sid) { + // First service on an empty board — create the home section. + const sec = await board.addSection("Services"); + if (!sec) throw new Error("could not create section"); + sid = sec.id; + } + const created = await board.addItem({ + sectionId: sid, + kind: "service", + name: name.trim(), + icon, + urls: cleanUrls, + }); + if (created && file) await board.uploadIcon(created.id, file); + } + onOpenChange(false); + } catch (e) { + setErr(e instanceof Error ? e.message : "save failed"); + } finally { + setBusy(false); + } + } + + async function remove() { + if (!item) return; + setBusy(true); + await board.deleteItem(item.id); + setBusy(false); + onOpenChange(false); + } + + return ( + + + + {editing ? "Edit service" : "Add service"} + + {editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."} + + + +
+
+
+ + setName(e.target.value)} placeholder="Pi-hole" autoFocus /> +
+ {!editing && board.sections.length > 0 && ( +
+ + +
+ )} +
+ +
+ + {urls.map((u, i) => ( +
+ setUrl(i, { url: e.target.value })} + placeholder="https://…" + className="font-mono text-[12px]" + /> + setUrl(i, { label: e.target.value })} + placeholder="label" + className="w-[110px] shrink-0" + list="url-labels" + /> + +
+ ))} + + + +
+ +
+ +
+ + setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" /> + + setErr("")} + /> +
+ {!icon && diSlug(name) ? ( + + ) : null} +

URL or uploaded file; blank shows a letter tile.

+
+ + {err ?

{err}

: null} +
+ + + {editing ? ( + + ) : null} + + + +
+
+ ); +} + +// Small live preview for the icon URL — hides itself on load failure so a +// dead URL reads as "no preview", not a broken image. +function IconPreview({ url }: { url: string }) { + const [failed, setFailed] = useState(false); + useEffect(() => setFailed(false), [url]); + if (!url || failed) { + return ; + } + return ( + setFailed(true)} + className="size-[30px] shrink-0 rounded-[7px] border border-border bg-icon-bg object-contain p-1" + /> + ); +} diff --git a/apps/frontend/src/components/SettingsDialog.tsx b/apps/frontend/src/components/SettingsDialog.tsx new file mode 100644 index 0000000..c81b73c --- /dev/null +++ b/apps/frontend/src/components/SettingsDialog.tsx @@ -0,0 +1,106 @@ +import { useRef, useState } from "react"; +import { Download, Upload } from "lucide-react"; +import { useBoard } from "@/board/store"; +import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { + const board = useBoard(); + const fileRef = useRef(null); + const [msg, setMsg] = useState(""); + + async function exportJson() { + const res = await fetch("/api/export"); + if (!res.ok) return; + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "dash-export.json"; + a.click(); + URL.revokeObjectURL(a.href); + } + + async function importJson(file: File) { + setMsg(""); + const res = await fetch("/api/import", { method: "POST", body: file }); + if (res.ok) { + location.reload(); // import replaces state; a reload re-syncs everything + } else { + setMsg("Import failed — not a Dash export?"); + } + } + + return ( + + + + Settings + Board layout, theme, and backup. + + +
+
+ +
+ {rendererList.map((r) => ( + + ))} +
+
+ +
+ + +
+ +
+ + + { + const f = e.target.files?.[0]; + if (f) void importJson(f); + e.target.value = ""; + }} + /> +
+ {msg ?

{msg}

: null} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/WidgetDialog.tsx b/apps/frontend/src/components/WidgetDialog.tsx new file mode 100644 index 0000000..99d62e5 --- /dev/null +++ b/apps/frontend/src/components/WidgetDialog.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { useBoard } from "@/board/store"; +import type { Item } from "@/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import type { components } from "@dash/api-client"; + +type WidgetType = components["schemas"]["WidgetType"]; + +// One generic dialog for every widget type — the registry's field list +// drives the inputs, so a new integration needs no dialog code. +export function WidgetDialog({ + open, + onOpenChange, + item, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + item?: Item | undefined; +}) { + const board = useBoard(); + const editing = Boolean(item); + + const [types, setTypes] = useState([]); + const [type, setType] = useState(""); + const [name, setName] = useState(""); + const [sectionId, setSectionId] = useState(""); + const [values, setValues] = useState>({}); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + useEffect(() => { + if (!open) return; + setErr(""); + // SAFETY: raw fetch — the generated client types only cover typed endpoints we call through api.ts. + fetch("/api/widgets/types") + .then((r) => r.json() as Promise) + .then(setTypes) + .catch(() => setTypes([])); + + const cfg = item?.config ?? null; + setType(cfg && typeof cfg.type === "string" ? cfg.type : ""); + setName(item?.name ?? ""); + setSectionId(item?.sectionId ?? board.sections[0]?.id ?? ""); + const init: Record = {}; + if (cfg) { + for (const [k, v] of Object.entries(cfg)) { + if (k !== "type" && typeof v === "string") init[k] = v; + } + } + setValues(init); + }, [open, item, board.sections]); + + const meta = types.find((t) => t.type === type); + + async function submit() { + if (!type) return setErr("Pick a widget type"), undefined; + if (!name.trim()) return setErr("Name is required"), undefined; + if (meta?.fields.some((f) => f.required && !values[f.key]?.trim())) { + return setErr("Fill in the required fields"), undefined; + } + setBusy(true); + try { + const config: NonNullable = { type }; + for (const f of meta?.fields ?? []) { + const v = values[f.key]?.trim(); + if (v) config[f.key] = v; + } + if (editing && item) { + await board.patchItem(item.id, { name: name.trim(), config }); + } else { + let sid = sectionId; + if (!sid) { + const sec = await board.addSection("Widgets"); + if (!sec) throw new Error("could not create section"); + sid = sec.id; + } + await board.addItem({ sectionId: sid, kind: "widget", name: name.trim(), config, urls: [] }); + } + onOpenChange(false); + } catch (e) { + setErr(e instanceof Error ? e.message : "save failed"); + } finally { + setBusy(false); + } + } + + return ( + + + + {editing ? "Edit widget" : "Add widget"} + + {editing ? "Update widget settings." : "Pick a type and point it at your service."} + + + +
+
+
+ + setName(e.target.value)} placeholder="Pi-hole" autoFocus /> +
+ {!editing && board.sections.length > 0 && ( +
+ + +
+ )} +
+ +
+ + + {meta?.description ? ( +

{meta.description}

+ ) : null} +
+ + {meta?.fields.map((f) => ( +
+ + setValues((v) => ({ ...v, [f.key]: e.target.value }))} + className="font-mono text-[12px]" + /> +
+ ))} + + {err ?

{err}

: null} +
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..9527229 --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0 active:scale-[0.97]", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + outline: "border border-border bg-surface text-text-dim hover:border-border-strong hover:text-text", + ghost: "text-text-dim hover:bg-surface-hover hover:text-text", + destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20", + }, + size: { + default: "h-[34px] px-3.5", + sm: "h-8 px-3 text-xs", + icon: "size-[34px]", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ; +} + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..30f28d5 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogClose = DialogPrimitive.Close; + +function DialogContent({ + className, children, showCloseButton = true, ...props +}: React.ComponentProps & { showCloseButton?: boolean }) { + return ( + + + + {children} + {showCloseButton ? ( + + + Close + + ) : null} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter }; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..2d4f7fa --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/components/ui/label.tsx b/apps/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..926ae62 --- /dev/null +++ b/apps/frontend/src/components/ui/label.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/frontend/src/components/ui/popover.tsx b/apps/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..65d526f --- /dev/null +++ b/apps/frontend/src/components/ui/popover.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; +const PopoverAnchor = PopoverPrimitive.Anchor; +const PopoverTrigger = PopoverPrimitive.Trigger; + +function PopoverContent({ + className, + align = "start", + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent }; diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..3fed064 --- /dev/null +++ b/apps/frontend/src/components/ui/select.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectValue = SelectPrimitive.Value; + +function SelectTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + span]:truncate", + className, + )} + {...props} + > + {children} + + + + + ); +} + +function SelectContent({ className, children, ...props }: React.ComponentProps) { + return ( + + + {children} + + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css new file mode 100644 index 0000000..39fdcec --- /dev/null +++ b/apps/frontend/src/index.css @@ -0,0 +1,141 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +/* Design tokens — ported from mockups/d-bento-mono.html. + Semantic vars flip on [data-theme]; Tailwind utilities map via @theme. + shadcn/ui names (background, popover, ...) alias the same tokens. */ +:root { + --ease: cubic-bezier(0.2, 0, 0, 1); + --radius: 0.5rem; +} + +[data-theme="dark"] { + --bg: #0a0a0a; + --surface: #111111; + --surface-hover: #161616; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.14); + --text: #fafafa; + --text-dim: #a1a1aa; + --text-faint: #52525b; + --accent: #3b82f6; + --up: #22c55e; + --down: #f59e0b; + --destructive: #ef4444; + --icon-bg: #1c1c1e; + --popover: #141414; + + --background: var(--bg); + --foreground: var(--text); + --card: var(--surface); + --card-foreground: var(--text); + --popover-foreground: var(--text); + --primary: var(--text); + --primary-foreground: var(--bg); + --secondary: var(--surface); + --secondary-foreground: var(--text); + --muted: var(--surface-hover); + --muted-foreground: var(--text-dim); + --accent-foreground: var(--text); + --input: var(--border-strong); + --ring: var(--accent); +} + +[data-theme="light"] { + --bg: #fafafa; + --surface: #ffffff; + --surface-hover: #f4f4f5; + --border: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.16); + --text: #09090b; + --text-dim: #52525b; + --text-faint: #a1a1aa; + --accent: #2563eb; + --up: #16a34a; + --down: #d97706; + --destructive: #dc2626; + --icon-bg: #f4f4f5; + --popover: #ffffff; + + --background: var(--bg); + --foreground: var(--text); + --card: var(--surface); + --card-foreground: var(--text); + --popover-foreground: var(--text); + --primary: var(--text); + --primary-foreground: var(--bg); + --secondary: var(--surface); + --secondary-foreground: var(--text); + --muted: var(--surface-hover); + --muted-foreground: var(--text-dim); + --accent-foreground: var(--text); + --input: var(--border-strong); + --ring: var(--accent); +} + +@theme inline { + --color-bg: var(--bg); + --color-surface: var(--surface); + --color-surface-hover: var(--surface-hover); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-text: var(--text); + --color-text-dim: var(--text-dim); + --color-text-faint: var(--text-faint); + --color-accent: var(--accent); + --color-up: var(--up); + --color-down: var(--down); + --color-destructive: var(--destructive); + --color-icon-bg: var(--icon-bg); + --color-popover: var(--popover); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent-foreground: var(--accent-foreground); + --color-input: var(--input); + --color-ring: var(--ring); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: "Geist Mono Variable", ui-monospace, monospace; + --font-serif: "Instrument Serif", Georgia, serif; + --font-terminal: "JetBrains Mono Variable", ui-monospace, monospace; +} + +@layer base { + html { + -webkit-font-smoothing: antialiased; + } + + body { + background: var(--bg); + color: var(--text); + min-height: 100dvh; + transition: + background 200ms var(--ease), + color 200ms var(--ease); + } + + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } + } +} diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts new file mode 100644 index 0000000..3200be2 --- /dev/null +++ b/apps/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 0000000..274263d --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import "@fontsource-variable/geist"; +import "@fontsource-variable/geist-mono"; +import "@fontsource/instrument-serif"; +import "@fontsource-variable/jetbrains-mono"; +import "./index.css"; + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +const root = document.getElementById("root"); +if (!root) throw new Error("#root missing"); + +createRoot(root).render( + + + , +); diff --git a/apps/frontend/src/renderers/bento/index.tsx b/apps/frontend/src/renderers/bento/index.tsx new file mode 100644 index 0000000..539532a --- /dev/null +++ b/apps/frontend/src/renderers/bento/index.tsx @@ -0,0 +1,152 @@ +import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useItemDnd, useSectionDnd } from "@/board/dnd"; +import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives"; +import { WidgetContent } from "@/widgets"; +import type { + BoardViewProps, + ItemViewProps, + RendererViews, + SectionViewProps, +} from "@/renderers/types"; + +// Bento — default renderer. Mixed-size grid, hairline section headers, +// status chips carry the only color. Ported from mockups/d-bento-mono.html. + +function BoardView({ children }: BoardViewProps) { + return
{children}
; +} + +function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) { + const dnd = useSectionDnd(section.id); + return ( +
+
+ + + +
+ {!section.collapsed && children} +
+ ); +} + +function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + const host = item.urls[0] ? hostOf(item.urls[0].url) : ""; + return ( + + {(open) => ( +
+ +
+
{item.name}
+ {host ?
{host}
: null} +
+ {item.urls.length > 0 && } + +
+ )} +
+ ); +} + +// Widget tile: full row on small screens, half row (span 3) on wide — +// matching the mockup's wider widget cells. +function WidgetView({ item, sectionId, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + return ( +
+ + +
+ ); +} + +export const bento: RendererViews = { + BoardView, + SectionView, + ItemView, + WidgetView, + itemsGridClass: "grid grid-cols-2 min-[620px]:grid-cols-4 min-[900px]:grid-cols-6 gap-2.5", +}; diff --git a/apps/frontend/src/renderers/cards/index.tsx b/apps/frontend/src/renderers/cards/index.tsx new file mode 100644 index 0000000..bc9a365 --- /dev/null +++ b/apps/frontend/src/renderers/cards/index.tsx @@ -0,0 +1,144 @@ +import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useItemDnd, useSectionDnd } from "@/board/dnd"; +import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives"; +import { WidgetContent } from "@/widgets"; +import type { + BoardViewProps, + ItemViewProps, + RendererViews, + SectionViewProps, +} from "@/renderers/types"; + +// Cards — uniform compact grid. Second renderer; proves the seam while the +// code is small. Ported from mockups/a-vercel-mono.html. + +function BoardView({ children }: BoardViewProps) { + return
{children}
; +} + +function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) { + const dnd = useSectionDnd(section.id); + return ( +
+
+ + + + +
+ {!section.collapsed && children} +
+ ); +} + +function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + const host = item.urls[0] ? hostOf(item.urls[0].url) : ""; + return ( + + {(open) => ( +
+ +
+
{item.name}
+ {host ?
{host}
: null} +
+ {item.urls.length > 1 && ( + + {item.urls.length} + + )} + {item.urls.length > 0 && } + +
+ )} +
+ ); +} + +function WidgetView({ item, sectionId, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + return ( +
+ + +
+ ); +} + +export const cards: RendererViews = { + BoardView, + SectionView, + ItemView, + WidgetView, + itemsGridClass: "grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5", +}; diff --git a/apps/frontend/src/renderers/editorial/index.tsx b/apps/frontend/src/renderers/editorial/index.tsx new file mode 100644 index 0000000..4d1c0be --- /dev/null +++ b/apps/frontend/src/renderers/editorial/index.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from "react"; +import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useItemDnd, useSectionDnd } from "@/board/dnd"; +import { useBoard } from "@/board/store"; +import { IconImg, ItemAnchor, hostOf } from "@/board/primitives"; +import { WidgetContent } from "@/widgets"; +import type { + BoardViewProps, + ItemViewProps, + RendererViews, + SectionViewProps, +} from "@/renderers/types"; + +// Index — editorial/list renderer. Serif masthead + section heads, numbered +// full-width rows, stat strip. Ported from mockups/e-editorial.html. + +function BoardView({ children }: BoardViewProps) { + const board = useBoard(); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + + const services = board.sections.flatMap((s) => s.items).filter((i) => i.kind !== "widget"); + const widgets = board.sections.flatMap((s) => s.items).filter((i) => i.kind === "widget"); + const up = services.filter((i) => board.statuses[i.id]?.status === "up").length; + const down = services.filter((i) => board.statuses[i.id]?.status === "down").length; + + const time = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }).format(now); + const date = new Intl.DateTimeFormat(undefined, { + weekday: "long", day: "numeric", month: "long", year: "numeric", + }).format(now); + + const cells: [string, string, string][] = [ + ["Local time", time, date.split(",")[0] ?? ""], + ["Services", String(services.length), `${board.sections.length} sections`], + ["Up", String(up), "responding"], + ["Down", String(down), down === 0 ? "all green" : "offline"], + ["Widgets", String(widgets.length), "registered"], + ]; + + return ( +
+
+

+ Dash. +

+ + {date} + +
+ +
+ {cells.map(([k, v, s]) => ( +
+
{k}
+
{v}
+
{s}
+
+ ))} +
+ +
{children}
+
+ ); +} + +function SectionView({ section, index, onToggle, onDelete, children }: SectionViewProps) { + const dnd = useSectionDnd(section.id); + return ( +
+
+ + + +
+ {!section.collapsed && children} +
+ ); +} + +function ItemView({ item, index, sectionId, status, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + const host = item.urls[0] ? hostOf(item.urls[0].url) : ""; + const state = status?.status; + return ( + + {(open) => ( +
+ + {String(index + 1).padStart(2, "0")} + + + + {item.name} + {host ? {host} : null} + + {host} + + {item.urls.slice(0, 2).map((u) => u.label ? ( + + {u.label} + + ) : null)} + + {item.urls.length > 0 ? ( + + + {state ?? "…"} + + ) : } + → + +
+ )} +
+ ); +} + +function WidgetView({ item, index, sectionId, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + return ( +
+ + {String(index + 1).padStart(2, "0")} + + + {item.name} +
+ +
+ +
+ ); +} + +export const editorial: RendererViews = { + BoardView, + SectionView, + ItemView, + WidgetView, + itemsGridClass: "flex flex-col", +}; diff --git a/apps/frontend/src/renderers/index.ts b/apps/frontend/src/renderers/index.ts new file mode 100644 index 0000000..1a02610 --- /dev/null +++ b/apps/frontend/src/renderers/index.ts @@ -0,0 +1,29 @@ +import { bento } from "./bento"; +import { cards } from "./cards"; +import { editorial } from "./editorial"; +import { terminal } from "./terminal"; +import type { RendererViews } from "./types"; +import type { RendererName } from "@/types"; + +// Renderer registry — settings.renderer picks the active one. +export const rendererList = ["bento", "cards", "index", "console"] as const satisfies readonly RendererName[]; +export const renderers: Record = { + bento, + cards, + index: editorial, + console: terminal, +}; + +export const rendererLabels: Record = { + bento: "Bento", + cards: "Cards", + index: "Index", + console: "Console", +}; + +export const rendererDescriptions: Record = { + bento: "mixed-size tile grid", + cards: "uniform compact cards", + index: "editorial list · masthead", + console: "terminal panels · mono", +}; diff --git a/apps/frontend/src/renderers/terminal/index.tsx b/apps/frontend/src/renderers/terminal/index.tsx new file mode 100644 index 0000000..51f2a1f --- /dev/null +++ b/apps/frontend/src/renderers/terminal/index.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from "react"; +import { ChevronDown, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useItemDnd, useSectionDnd } from "@/board/dnd"; +import { useBoard } from "@/board/store"; +import { IconImg, ItemAnchor, hostOf } from "@/board/primitives"; +import { WidgetContent } from "@/widgets"; +import type { + BoardViewProps, + ItemViewProps, + RendererViews, + SectionViewProps, +} from "@/renderers/types"; + +// Console — terminal renderer. Mono everything, $ banner, bordered stat +// panels, ## section heads, › prompt rows. Ported from mockups/f-console.html. + +function BoardView({ children }: BoardViewProps) { + const board = useBoard(); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + + const items = board.sections.flatMap((s) => s.items); + const services = items.filter((i) => i.kind !== "widget"); + const widgets = items.filter((i) => i.kind === "widget"); + const endpoints = services.reduce((n, i) => n + i.urls.length, 0); + const up = services.filter((i) => board.statuses[i.id]?.status === "up").length; + const down = services.filter((i) => board.statuses[i.id]?.status === "down").length; + const probed = up + down; + + const time = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }).format(now); + const date = new Intl.DateTimeFormat(undefined, { + weekday: "short", month: "short", day: "numeric", + }).format(now); + + const panels: { head: string; v: string; s: string; live?: boolean; wide?: boolean }[] = [ + { head: "clock --local", v: time, s: date, live: true, wide: true }, + { head: "services --up", v: String(up), s: `of ${probed} probed` }, + { head: "services --down", v: String(down), s: down === 0 ? "all green" : "needs attention" }, + { head: "widgets", v: String(widgets.length), s: "registered" }, + { head: "endpoints", v: String(endpoints), s: "urls indexed" }, + { head: "uptime --probes", v: probed > 0 ? `${Math.round((up / probed) * 100)}%` : "—", s: `${up}/${probed} responding` }, + { head: "sections", v: String(board.sections.length), s: "groups" }, + ]; + + return ( +
+
+ {`$ dash status --all\n`} + {services.length} services + {` indexed · `} + {up} up + {down > 0 ? {` · `}{down} down : ` · all green`} + {` · `} + {widgets.length} widgets + {`\ndrag rows to reorder · click rows with multiple endpoints to choose`} +
+ +
+ {panels.map((p) => ( +
+
+ {p.head} + {p.live ? ● live : null} +
+
+
{p.v}
+
{p.s}
+
+
+ ))} +
+ +
{children}
+
+ ); +} + +function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) { + const dnd = useSectionDnd(section.id); + return ( +
+
+ + + +
+ {!section.collapsed && ( +
{children}
+ )} +
+ ); +} + +function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + const host = item.urls[0] ? hostOf(item.urls[0].url) : ""; + const state = status?.status; + return ( + + {(open) => ( +
+ › + + + {item.name} + {host ? {host} : null} + + + {host}{item.urls.length > 1 ? ` · ${item.urls.length} endpoints` : ""} + + {item.urls.length > 0 ? ( + + [ {state ?? "…"} ] + + ) : } + +
+ )} +
+ ); +} + +function WidgetView({ item, sectionId, onEdit }: ItemViewProps) { + const dnd = useItemDnd(sectionId, item.id); + return ( +
+ › + + {item.name} +
+ +
+ +
+ ); +} + +export const terminal: RendererViews = { + BoardView, + SectionView, + ItemView, + WidgetView, + itemsGridClass: "flex flex-col", +}; diff --git a/apps/frontend/src/renderers/types.ts b/apps/frontend/src/renderers/types.ts new file mode 100644 index 0000000..c3c720d --- /dev/null +++ b/apps/frontend/src/renderers/types.ts @@ -0,0 +1,39 @@ +import type { ComponentType, ReactNode } from "react"; +import type { Item, Section, Status } from "@/types"; + +// Renderer contract — same board state, shared dnd/popover/dialog primitives, +// different structure per style. New renderers implement this shape only. +// Dnd stays shared: views call useSectionDnd / useItemDnd hooks internally. + +export interface SectionViewProps { + section: Section; + index: number; + onToggle(): void; + onDelete(): void; + children: ReactNode; // rendered items grid (ItemSortZone output) +} + +export interface ItemViewProps { + item: Item; + index: number; + sectionId: string; + status?: Status | undefined; + onEdit(): void; +} + +export interface BoardViewProps { + children: ReactNode; // rendered sections +} + +export interface RendererViews { + /** Arrangement wrapper around the section list. */ + BoardView: ComponentType; + /** Section chrome: label, count, collapse, drag grip. */ + SectionView: ComponentType; + /** Service tile anatomy: icon, name, host, status, launch anchor. */ + ItemView: ComponentType; + /** Widget tile anatomy (Phase 2 fills in). */ + WidgetView: ComponentType; + /** Tailwind classes for the items grid inside a section. */ + itemsGridClass: string; +} diff --git a/apps/frontend/src/types.ts b/apps/frontend/src/types.ts new file mode 100644 index 0000000..b07cc50 --- /dev/null +++ b/apps/frontend/src/types.ts @@ -0,0 +1,34 @@ +import type { components, paths } from "@dash/api-client"; + +// Board entities — generated from openapi.yaml. Never hand-edit shapes here. +export type Section = components["schemas"]["Section"]; +export type Item = components["schemas"]["Item"]; +export type BoardUrl = components["schemas"]["Url"]; +export type UrlInput = components["schemas"]["UrlInput"]; +export type ItemInput = components["schemas"]["ItemInput"]; +export type ItemPatch = components["schemas"]["ItemPatch"]; +export type Status = components["schemas"]["Status"]; +export type StatusMap = Record; +export type ExportPayload = components["schemas"]["Export"]; + +export type Theme = "dark" | "light"; +export type RendererName = "bento" | "cards" | "index" | "console"; + +/** Wire shape of GET /api/settings — a free-form map we narrow at the boundary. */ +export type SettingsWire = NonNullable< + paths["/api/settings"]["get"]["responses"]["200"]["content"]["application/json"] +>; + +export interface Prefs { + theme: Theme; + renderer: RendererName; +} + +/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */ +export function parsePrefs(raw: SettingsWire): Prefs { + const r = raw.renderer; + return { + theme: raw.theme === "light" ? "light" : "dark", + renderer: r === "cards" || r === "index" || r === "console" ? r : "bento", + }; +} diff --git a/apps/frontend/src/vite-env.d.ts b/apps/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/frontend/src/widgets/blocker.tsx b/apps/frontend/src/widgets/blocker.tsx new file mode 100644 index 0000000..12a4483 --- /dev/null +++ b/apps/frontend/src/widgets/blocker.tsx @@ -0,0 +1,43 @@ +import type { Item } from "@/types"; +import type { WidgetData } from "@/widgets/index"; + +// Shared DNS-blocker layout: big block-rate, queries + blocked counts. +// Pi-hole and AdGuard return the same fields from their fetchers. + +function num(d: WidgetData | undefined, k: string): number { + const v = d?.[k]; + return typeof v === "number" ? v : 0; +} + +const fmt = (n: number) => + n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); + +function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | undefined; error: string | undefined }) { + if (error) { + return {item.name} — {error}; + } + const pct = num(data, "blockedPercent"); + return ( +
+
+ + {data ? `${pct.toFixed(1)}%` : "…"} + + blocked +
+
+ {fmt(num(data, "queriesToday"))} queries + {fmt(num(data, "blockedToday"))} blocked + {typeof data?.avgProcessMs === "number" && {num(data, "avgProcessMs").toFixed(0)} ms avg} +
+
+ ); +} + +export function PiholeWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) { + return ; +} + +export function AdguardWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) { + return ; +} diff --git a/apps/frontend/src/widgets/clock.tsx b/apps/frontend/src/widgets/clock.tsx new file mode 100644 index 0000000..dadd756 --- /dev/null +++ b/apps/frontend/src/widgets/clock.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import type { Item } from "@/types"; +import { cfgStr } from "@/widgets/index"; + +// Clock — fully client-side; ticks once a second. +export function ClockWidget({ item }: { item: Item }) { + const tz = cfgStr(item, "timezone") || undefined; + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + + const time = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZone: tz, + }).format(now); + const date = new Intl.DateTimeFormat(undefined, { + weekday: "short", month: "short", day: "numeric", timeZone: tz, + }).format(now); + + return ( +
+ + {time} + + + {date}{tz ? ` · ${tz.split("/").pop()?.replace("_", " ")}` : ""} + +
+ ); +} diff --git a/apps/frontend/src/widgets/immich.tsx b/apps/frontend/src/widgets/immich.tsx new file mode 100644 index 0000000..eb78cfa --- /dev/null +++ b/apps/frontend/src/widgets/immich.tsx @@ -0,0 +1,35 @@ +import type { Item } from "@/types"; +import type { WidgetData } from "@/widgets/index"; + +// Immich — photo/video counts and library footprint. + +function num(d: WidgetData | undefined, k: string): number { + const v = d?.[k]; + return typeof v === "number" ? v : 0; +} + +const fmtNum = (n: number) => + n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); + +const fmtBytes = (b: number) => + b >= 1 << 30 ? `${(b / (1 << 30)).toFixed(1)} GB` : `${(b / (1 << 20)).toFixed(0)} MB`; + +export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetData | undefined; error: string | undefined }) { + if (error) { + return {item.name} — {error}; + } + return ( +
+
+ + {data ? fmtNum(num(data, "photos")) : "…"} + + photos +
+
+ {fmtNum(num(data, "videos"))} videos + {fmtBytes(num(data, "usageBytes"))} +
+
+ ); +} diff --git a/apps/frontend/src/widgets/index.tsx b/apps/frontend/src/widgets/index.tsx new file mode 100644 index 0000000..848bfe1 --- /dev/null +++ b/apps/frontend/src/widgets/index.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import type { Item } from "@/types"; +import { ClockWidget } from "@/widgets/clock"; +import { PiholeWidget, AdguardWidget } from "@/widgets/blocker"; +import { ImmichWidget } from "@/widgets/immich"; + +// WidgetHost seam: renderers own the tile chrome; this maps config.type to +// the component that fills it. Adding a widget = one fetcher in Go + one +// entry here. + +export type WidgetComponent = (p: { item: Item; data: WidgetData | undefined; error: string | undefined }) => React.ReactNode; + +export interface WidgetData { + [key: string]: string | number | boolean | undefined; +} + +// Widget payloads are free-form per type; components read fields defensively. +function isData(v: unknown): v is WidgetData { + return typeof v === "object" && v !== null; +} + +interface WidgetRegistry { + [type: string]: { local?: boolean; view: WidgetComponent }; +} + +const registry: WidgetRegistry = { + clock: { local: true, view: ClockWidget }, + pihole: { view: PiholeWidget }, + adguard: { view: AdguardWidget }, + immich: { view: ImmichWidget }, +}; + +export function cfgStr(item: Item, key: string): string { + const cfg = item.config; + if (!cfg) return ""; + const v = cfg[key]; + return typeof v === "string" ? v : ""; +} + +export function cfgType(item: Item): string { + return cfgStr(item, "type"); +} + +/** Polls /api/widgets/:id/data every 30s for server-backed widgets. */ +export function useWidgetData(item: Item): { data: WidgetData | undefined; error: string | undefined } { + const type = cfgType(item); + const local = registry[type]?.local ?? false; + const [state, setState] = useState<{ data: WidgetData | undefined; error: string | undefined }>({ + data: undefined, error: undefined, + }); + + useEffect(() => { + if (local || !type) return; + let dead = false; + let timer: ReturnType; + + const tick = async () => { + try { + const res = await fetch(`/api/widgets/${item.id}/data`); + const body: unknown = await res.json(); + if (dead) return; + if (!res.ok) { + let msg = "fetch failed"; + if (typeof body === "object" && body !== null && "error" in body) { + msg = String(body.error); + } + setState((s) => ({ data: s.data, error: msg })); + } else { + let data: WidgetData = {}; + if (typeof body === "object" && body !== null && "data" in body && isData(body.data)) { + data = body.data; + } + setState({ data, error: undefined }); + } + } catch { + if (!dead) setState((s) => ({ data: s.data, error: "offline" })); + } + if (!dead) timer = setTimeout(tick, 30_000); + }; + void tick(); + return () => { + dead = true; + clearTimeout(timer); + }; + }, [item.id, type, local]); + + return state; +} + +/** WidgetContent renders inside a renderer's tile chrome. */ +export function WidgetContent({ item }: { item: Item }) { + const { data, error } = useWidgetData(item); + const type = cfgType(item); + const entry = registry[type]; + if (!entry) { + return {item.name || "widget"}; + } + return <>{entry.view({ item, data, error })}; +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000..053348a --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts new file mode 100644 index 0000000..9f53dca --- /dev/null +++ b/apps/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + }, + server: { + // Backend API during development (DASH_DEV=1 go run ./cmd/dash). + proxy: { "/api": "http://localhost:8080" }, + }, +}); diff --git a/data/.keep b/data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..839b9b1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + dash: + build: + context: . + dockerfile: infra/Dockerfile + ports: + - "3000:3000" + volumes: + - ./data:/data + restart: unless-stopped diff --git a/docs/specs/2026-09-16-dash-design.md b/docs/specs/2026-09-16-dash-design.md new file mode 100644 index 0000000..e5219f7 --- /dev/null +++ b/docs/specs/2026-09-16-dash-design.md @@ -0,0 +1,135 @@ +# Dash — Design Spec & Roadmap + +Date: 2026-09-16 +Status: proposed — pending UI direction pick (see `mockups/`) + +## 1. Product + +Self-hosted homelab dashboard. A visual launchpad for services (local + external) with live widgets. CasaOS-grade ease of use, Vercel-grade design. Single Docker container, no auth in v1. + +Differentiator: UI-first management (no YAML) + modern design. Homepage requires YAML, Homarr forces auth and complexity, Dashy is overloaded, CasaOS is an OS not a dashboard. + +## 2. Requirements (from prompt.md) + +| Req | Notes | +|---|---| +| Service cards | name, icon (URL or uploaded file), status indicator | +| Multi-URL services | each URL tagged `local` / `external` / custom label; 1 URL = direct nav, 2+ = popover chooser | +| Sections | named groups; collapse/expand; reorder sections and items via drag-drop; items move between sections | +| Add service | `+` button, dialog: name, URLs, icon | +| Widgets | live on the grid; v1: clock/timezones, Pi-hole, AdGuard Home, Immich; extensible registry | +| Themes | dark + light | +| No auth | v1; optional auth is post-1.0 | +| Lightweight | single container, single binary, <50 MB image target | + +## 3. Architecture + +``` +/apps/frontend React 18 + Vite + TS strict + Tailwind + shadcn/ui + dnd-kit +/apps/backend Go + Gin + zap, CGO-free +/packages/api-client generated from OpenAPI (single source of truth) +/infra Dockerfile multi-stage → ONE image; docker-compose.yml (1 service) +/data SQLite file + uploaded icons (bind-mounted) +``` + +### Approved deviations from locked stack + +- **SQLite** (`modernc.org/sqlite`, CGO-free) instead of Postgres. Approved 2026-09-16. Reason: ~100 rows of state, single-binary deployment is the product's core install feature. goose migrations still apply. +- **No DragonflyDB.** Widget responses cached in-process with a small TTL map. Add a real cache only if profiling demands it. + +Everything else per `tdvorak-fullstack` locked stack. + +### Key decisions + +- **Go embeds the frontend** via `embed.FS` → `docker run -v ./data:/data -p 3000:3000 dash` is the entire install. +- **Widget proxy server-side.** Pi-hole/AdGuard/etc. APIs need secrets and are not CORS-safe. Backend exposes `GET /api/widgets/:id/data`; a registry maps widget type → fetcher. New integration = one Go file + one React component. +- **Ordering** via fractional position column (`position REAL`) — drag-drop writes one row, not a renumber of the set. +- **Icons** stored in `data/icons/`; dashboard-icons pack integration for auto-suggest in Phase 3. + +## 4. Data model (SQLite) + +```sql +sections(id TEXT PK, name TEXT, position REAL, collapsed INT, created_at) +items(id TEXT PK, section_id FK, kind TEXT, -- 'service' | 'widget' + name TEXT, icon TEXT, -- icon: path or URL + position REAL, config JSON, created_at) -- widget cfg or null +urls(id TEXT PK, item_id FK, url TEXT, label TEXT, position REAL) +settings(key TEXT PK, value JSON) -- theme, wallpaper, widget secrets ref +``` + +Widget secrets (API keys) live in `config JSON` for v1; encrypted-at-rest only if auth lands later. + +## 5. API (OpenAPI contract — sketch) + +``` +GET/POST/PATCH/DELETE /api/sections +POST /api/sections/reorder +GET/POST/PATCH/DELETE /api/items +POST /api/items/reorder +POST /api/items/:id/icon (upload) +GET /api/widgets/types (registry metadata for UI) +GET /api/widgets/:id/data (live fetch, cached ~30s) +GET/PUT /api/settings +GET /api/export (full JSON backup) +POST /api/import +GET /api/healthz +``` + +Frontend never hand-writes API types — generated client from `openapi.yaml`. + +## 6. Frontend structure + +- `ServiceCard` — icon, name, host, status dot, multi-URL badge → `UrlPopover` +- `Section` — header (label, count, collapse chevron, drag handle) + `SortableContext` grid +- `WidgetHost` — renders widget component by `config.type`, polls `/api/widgets/:id/data` +- `AddServiceDialog`, `EditServiceDialog` (shadcn Dialog + Form) +- `CommandPalette` (⌘K) — Phase 3 +- dnd-kit `DndContext` at board level; sections and items both sortable + +## 7. UI system — DECIDED 2026-09-16 + +**The app ships multiple layouts, not one.** Same board state, same interactions (drag-drop, collapse, URL popover, add/edit), different structure per style. This is a core feature, not a theme afterthought. + +**Primary renderer: `d-bento-mono.html`** — bento grid on the monochrome zinc palette. Geist + Geist Mono, hairline borders, no glow, status chips carry the only color (green/amber semantic). + +**Full renderer set:** + +| Renderer | Source mockup | Structure | +|---|---|---| +| Bento (default) | `d-bento-mono.html` | mixed-size grid, widgets inline as large tiles | +| Cards | `a-vercel-mono.html` | uniform compact card grid, widgets as cards | +| Index | `e-editorial.html` | serif section headers, full-width rows, masthead stat strip | +| Console | `f-console.html` | bordered panels/tables, JetBrains Mono, `[ up ]` tags | + +Rejected and deleted: bento-with-glow (B), casa glass (C). + +### Renderer contract (architectural requirement) + +``` +renderers/ + / + BoardView (grid/list arrangement, section placement) + SectionView (header chrome: label, count, collapse, drag handle) + ItemView (tile/card/row anatomy: icon, name, host, status, popover anchor) + WidgetView (widget tile/panel/strip-cell anatomy) +``` + +- Board state (sections, items, order, collapse) lives in one store, renderer-agnostic. +- Interactions (dnd-kit, popover, dialogs) are provided by shared primitives the renderer composes — renderers never reimplement dnd or popover logic. +- Design tokens (`--bg`, `--surface`, `--border`, `--text`, …) are shared CSS vars; each renderer maps them to its own chrome. Dark/light stays orthogonal to renderer choice. +- Setting: `settings.renderer` — user picks layout in settings; persisted like theme. + +Building the second renderer (Cards) inside Phase 1 is deliberate: it validates the seam while the code is still small. + +## 8. Roadmap + +Canonical roadmap lives in [`ROADMAP.md`](../../ROADMAP.md) at repo root — single source of truth, kept versioned with the code. + +### Explicit non-goals for v1 +Auth, Postgres, multi-user, mobile app, container management, YAML-as-source-of-truth. + +## 9. Testing + +- Go: table-driven tests for widget fetchers + reorder logic (`go test ./...`) +- TS: `tsc --noEmit` + oxlint gate; dnd reducer unit tests only if ordering logic grows +- Smoke: `docker compose up` → create section → add service → drag → export JSON diff --git a/infra/Dockerfile b/infra/Dockerfile new file mode 100644 index 0000000..22f1ca5 --- /dev/null +++ b/infra/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +# ---- frontend ---- +FROM node:24-alpine AS frontend +WORKDIR /src +COPY package.json package-lock.json openapi.yaml ./ +COPY apps/frontend/package.json apps/frontend/package.json +COPY packages/api-client/package.json packages/api-client/package.json +RUN npm ci +COPY apps/frontend apps/frontend +COPY packages/api-client packages/api-client +RUN npm run build -w @dash/frontend + +# ---- backend ---- +FROM golang:1.27-alpine AS backend +WORKDIR /src +COPY apps/backend/go.mod apps/backend/go.sum ./ +RUN go mod download +COPY apps/backend ./ +COPY --from=frontend /src/apps/frontend/dist ./internal/web/dist +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/dash ./cmd/dash + +# ---- final: static binary on distroless ---- +# Runs as root so the ./data bind mount stays writable without PUID plumbing. +# Attack surface is a single static binary; no shell, no package manager. +FROM gcr.io/distroless/static-debian12 +COPY --from=backend /out/dash /dash +ENV DASH_PORT=3000 DASH_DATA_DIR=/data +EXPOSE 3000 +VOLUME ["/data"] +ENTRYPOINT ["/dash"] diff --git a/mockups/a-vercel-mono.html b/mockups/a-vercel-mono.html new file mode 100644 index 0000000..3fe87a5 --- /dev/null +++ b/mockups/a-vercel-mono.html @@ -0,0 +1,441 @@ + + + + + +Dash — A · Vercel Mono + + + + + +
+
+ + Dash +
+ + + +
+ +
+
+

Overview

+ ● 17 services · +
+ +
+
+ +

Network

4 + +
+
+
+
+
Pi-hole
10.0.0.2
+ 2 +
+
+
+
+
AdGuard Home
10.0.0.3:3000
+ +
+
+
+
Nginx Proxy Mgr
10.0.0.2:81
+ 2 +
+
+
+
+
Uptime Kuma
status.tdvorak.dev
+ 2 +
+
+
+
+ +
+
+ +

Media

6 + +
+
+
+
+
Jellyfin
watch.tdvorak.dev
+ 2 +
+
+
+
+
Immich
photos.tdvorak.dev
+ 2 +
+
+
+
+
Navidrome
10.0.0.10:4533
+ +
+
+
+
qBittorrent
10.0.0.10:8080
+ +
+
+
+
Sonarr
10.0.0.10:8989
+ +
+
+
+
Radarr
10.0.0.10:7878
+ +
+
+
+ +
+
+ +

Infrastructure

7 + +
+
+
+
+
Proxmox
10.0.0.4:8006
+ +
+
+
+
Portainer
10.0.0.4:9000
+ +
+
+
+
TrueNAS
nas.tdvorak.dev
+ 2 +
+
+
+
+
Grafana
10.0.0.4:3100
+ +
+
+
+
Vaultwarden
vault.tdvorak.dev
+ +
+
+
+
Home Assistant
home.tdvorak.dev
+ 2 +
+
+
+
+
Nextcloud
cloud.tdvorak.dev
+ +
+
+
+ +
+
+ +

Widgets

4 + +
+
+
+
Time
+
--:--
+
PRG NYC TYO
+
+
+
Pi-hole
+
24.6%
+
ads blocked · 84,213 queries
+
+
+
AdGuard
+
11.2%
+
blocked · 31 clients
+
+
+
Immich
+
12,483
+
photos · 96.4 GB
+
+
+
+ +

Mockup A — Vercel Mono. Drag cards to reorder. Click a card with a “2” badge to see the URL chooser.

+
+ + + + diff --git a/mockups/d-bento-mono.html b/mockups/d-bento-mono.html new file mode 100644 index 0000000..49aa017 --- /dev/null +++ b/mockups/d-bento-mono.html @@ -0,0 +1,419 @@ + + + + + +Dash — D · Bento Mono + + + + + + +
+
+ + Dash +
+ + + +
+ +
+
+ +

Status

05 + +
+ +
+
Local time ● LIVE
+
--:--:--
+
PRG NYC TYO
+
+
+
Pi-hole · today
+
24.6%
+
20,721 ads blocked · 84,213 queries
+
+
+
+
AdGuard
+
11.2%
+
blocked · 31 clients
+
+
+
Immich
+
12,483
+
photos · 96.4 GB
+
+
+
Uptime · 30d
+
99.7%
+
1 incident · Radarr
+
+
+
Storage
+
61%
+
14.2 / 23.2 TB
+
+
+
Transfer
+
42 MB/s
+
↓ 38.4 · ↑ 3.6 · 7 active
+
+ +

Network

04 + +
+ +
+
+
Pi-hole
10.0.0.2
+ up +
+
+
+
+
AdGuard Home
10.0.0.3:3000
+ up +
+
+
+
Nginx Proxy Mgr
10.0.0.2:81
+ up +
+
+
+
+
Uptime Kuma
status.tdvorak.dev
+ up +
+
+ +

Media

06 + +
+ +
+
+
Jellyfin
watch.tdvorak.dev
+ up +
+
+
+
+
Immich
photos.tdvorak.dev
+ up +
+
+
+
+
Navidrome
10.0.0.10:4533
+ up +
+
+
+
qBittorrent
10.0.0.10:8080
+ up +
+
+
+
Sonarr
10.0.0.10:8989
+ up +
+
+
+
Radarr
10.0.0.10:7878
+ down +
+ +

Infrastructure

07 + +
+ +
+
+
Proxmox
10.0.0.4:8006
+ up +
+
+
+
Portainer
10.0.0.4:9000
+ up +
+
+
+
TrueNAS
nas.tdvorak.dev
+ up +
+
+
+
+
Grafana
10.0.0.4:3100
+ up +
+
+
+
Vaultwarden
vault.tdvorak.dev
+ up +
+
+
+
Home Assistant
home.tdvorak.dev
+ up +
+
+
+
+
Nextcloud
cloud.tdvorak.dev
+ up +
+ +

Mockup D — Bento Mono. B's grid on A's palette: no glow, zinc only, status chips carry the color. Click multi-URL tiles for the chooser.

+
+
+ + + + diff --git a/mockups/e-editorial.html b/mockups/e-editorial.html new file mode 100644 index 0000000..6acb45c --- /dev/null +++ b/mockups/e-editorial.html @@ -0,0 +1,432 @@ + + + + + +Dash — E · Editorial + + + + + + + +
+
Dash.
+ + + +
+ +
+
+

Service Index

+ +
+ +
+
Local time
--:--
PRG · NYC · TYO
+
Pi-hole
24.6%
ads blocked today
+
AdGuard
11.2%
31 clients
+
Immich
12,483
photos · 96.4 GB
+
Uptime · 30d
99.7%
1 incident
+
+ +
+
01

Network

4 entries + +
+
+
+ 01 +
+ Pi-hole10.0.0.2 + localext + up→ +
+
+
+ 02 +
+ AdGuard Home10.0.0.3:3000 + local + up→ +
+
+ 03 +
+ Nginx Proxy Mgr10.0.0.2:81 + localext + up→ +
+
+
+ 04 +
+ Uptime Kumastatus.tdvorak.dev + localext + up→ +
+
+
+
+ +
+
02

Media

6 entries + +
+
+
+ 01 +
+ Jellyfinwatch.tdvorak.dev + localext + up→ +
+
+
+ 02 +
+ Immichphotos.tdvorak.dev + localext + up→ +
+
+
+ 03 +
+ Navidrome10.0.0.10:4533 + local + up→ +
+
+ 04 +
+ qBittorrent10.0.0.10:8080 + local + up→ +
+
+ 05 +
+ Sonarr10.0.0.10:8989 + local + up→ +
+
+ 06 +
+ Radarr10.0.0.10:7878 + local + down→ +
+
+
+ +
+
03

Infrastructure

7 entries + +
+
+
+ 01 +
+ Proxmox10.0.0.4:8006 + local + up→ +
+
+ 02 +
+ Portainer10.0.0.4:9000 + local + up→ +
+
+ 03 +
+ TrueNASnas.tdvorak.dev + localext + up→ +
+
+
+ 04 +
+ Grafana10.0.0.4:3100 + local + up→ +
+
+ 05 +
+ Vaultwardenvault.tdvorak.dev + ext + up→ +
+
+ 06 +
+ Home Assistanthome.tdvorak.dev + localext + up→ +
+
+
+ 07 +
+ Nextcloudcloud.tdvorak.dev + ext + up→ +
+
+
+ +
dash v0.1 · 17 services · 4 widgetsmockup E — editorial
+
+ + + + diff --git a/mockups/f-console.html b/mockups/f-console.html new file mode 100644 index 0000000..25352f7 --- /dev/null +++ b/mockups/f-console.html @@ -0,0 +1,435 @@ + + + + + +Dash — F · Console + + + + + +
+
dash
+ ~/board · 17 services · 5 widgets + + + +
+ +
+ + +
+
+
clock --local ● live
+
+
--:--:--
+
PRG NYC TYO
+
+
+
+
pihole
+
24.6%
ads blocked · 84,213 q
+
+
+
adguard
+
11.2%
blocked · 31 clients
+
+
+
immich
+
12,483
photos · 96.4 GB
+
+
+
storage
+
61%
14.2 / 23.2 TB
+
+
+
uptime --30d
+
99.7%
1 incident · radarr
+
+
+
transfer
+
42 MB/s
↓ 38.4 · ↑ 3.6
+
+
+ +
+
##

network

[4] + ──────────────────────────────────────────── + +
+
+
+ › +
+ pi-hole10.0.0.2 · 2 endpoints + [ up ] +
+
+
+ › +
+ adguard-home10.0.0.3:3000 + [ up ] +
+
+ › +
+ nginx-proxy-mgr10.0.0.2:81 · 2 endpoints + [ up ] +
+
+
+ › +
+ uptime-kumastatus.tdvorak.dev · 2 endpoints + [ up ] +
+
+
+
+ +
+
##

media

[6] + ──────────────────────────────────────────── + +
+
+
+ › +
+ jellyfinwatch.tdvorak.dev · 2 endpoints + [ up ] +
+
+
+ › +
+ immichphotos.tdvorak.dev · 2 endpoints + [ up ] +
+
+
+ › +
+ navidrome10.0.0.10:4533 + [ up ] +
+
+ › +
+ qbittorrent10.0.0.10:8080 + [ up ] +
+
+ › +
+ sonarr10.0.0.10:8989 + [ up ] +
+
+ › +
+ radarr10.0.0.10:7878 + [ down ] +
+
+
+ +
+
##

infrastructure

[7] + ──────────────────────────────────────────── + +
+
+
+ › +
+ proxmox10.0.0.4:8006 + [ up ] +
+
+ › +
+ portainer10.0.0.4:9000 + [ up ] +
+
+ › +
+ truenasnas.tdvorak.dev · 2 endpoints + [ up ] +
+
+
+ › +
+ grafana10.0.0.4:3100 + [ up ] +
+
+ › +
+ vaultwardenvault.tdvorak.dev + [ up ] +
+
+ › +
+ home-assistanthome.tdvorak.dev · 2 endpoints + [ up ] +
+
+
+ › +
+ nextcloudcloud.tdvorak.dev + [ up ] +
+
+
+ +

mockup F — console · [t] toggles theme · rows with “2 endpoints” open the chooser

+
+ + + + diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..5f4ba89 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,575 @@ +openapi: 3.1.0 +info: + title: Dash API + version: 0.1.0 + description: > + Single source of truth for the Dash API. The TypeScript client in + packages/api-client is generated from this file — do not hand-write + API types in the frontend. +servers: + - url: / +paths: + /api/healthz: + get: + operationId: healthz + summary: Liveness + database reachability probe + responses: + "200": + description: Service healthy + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + "503": + description: Database unreachable + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + + /api/sections: + get: + operationId: listSections + summary: Full board — sections with nested items and urls, ordered + responses: + "200": + description: Ordered board tree + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Section" + post: + operationId: createSection + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SectionInput" + responses: + "201": + description: Created section + content: + application/json: + schema: + $ref: "#/components/schemas/Section" + + /api/sections/{id}: + patch: + operationId: updateSection + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SectionPatch" + responses: + "200": + description: Updated section + content: + application/json: + schema: + $ref: "#/components/schemas/Section" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteSection + parameters: + - $ref: "#/components/parameters/id" + responses: + "204": + description: Section and its items deleted + + /api/sections/reorder: + post: + operationId: reorderSection + summary: Move a section between two neighbors (fractional position) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Reorder" + responses: + "200": + description: Reordered + content: + application/json: + schema: + $ref: "#/components/schemas/Section" + "404": + $ref: "#/components/responses/NotFound" + + /api/items: + post: + operationId: createItem + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ItemInput" + responses: + "201": + description: Created item + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + "400": + $ref: "#/components/responses/BadRequest" + + /api/items/{id}: + patch: + operationId: updateItem + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ItemPatch" + responses: + "200": + description: Updated item + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + "404": + $ref: "#/components/responses/NotFound" + delete: + operationId: deleteItem + parameters: + - $ref: "#/components/parameters/id" + responses: + "204": + description: Item deleted + + /api/items/{id}/icon: + post: + operationId: uploadIcon + summary: Upload an icon file; returns the stored icon reference + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "200": + description: Icon stored + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + "400": + $ref: "#/components/responses/BadRequest" + + /api/items/reorder: + post: + operationId: reorderItem + summary: Move an item between two neighbors, optionally across sections + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ItemReorder" + responses: + "200": + description: Reordered + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + "404": + $ref: "#/components/responses/NotFound" + + /api/icons/{file}: + get: + operationId: getIcon + summary: Serve an uploaded icon file + parameters: + - name: file + in: path + required: true + schema: + type: string + responses: + "200": + description: Icon bytes + "404": + $ref: "#/components/responses/NotFound" + + /api/widgets/types: + get: + operationId: widgetTypes + summary: Widget registry metadata — drives the add-widget dialog + responses: + "200": + description: Registered widget types + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WidgetType" + + /api/widgets/{id}/data: + get: + operationId: widgetData + summary: Live data for one widget item (~30s server-side cache) + parameters: + - $ref: "#/components/parameters/id" + responses: + "200": + description: Widget payload; shape depends on config.type + content: + application/json: + schema: + $ref: "#/components/schemas/WidgetData" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "502": + description: Upstream service unreachable + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/status: + get: + operationId: getStatus + summary: Reachability of each service item's primary URL (~60s server cache) + responses: + "200": + description: Status map keyed by item id + content: + application/json: + schema: + type: object + additionalProperties: + $ref: "#/components/schemas/Status" + + /api/settings: + get: + operationId: getSettings + responses: + "200": + description: All settings as key/value map + content: + application/json: + schema: + type: object + additionalProperties: true + put: + operationId: putSettings + summary: Merge settings (keys not sent are preserved) + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + "200": + description: Full settings map after merge + content: + application/json: + schema: + type: object + additionalProperties: true + + /api/export: + get: + operationId: exportBoard + summary: Full JSON backup (sections, items, urls, settings) + responses: + "200": + description: Board export + content: + application/json: + schema: + $ref: "#/components/schemas/Export" + + /api/import: + post: + operationId: importBoard + summary: Replace board state from an export payload + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Export" + responses: + "200": + description: Imported board tree + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Section" + "400": + $ref: "#/components/responses/BadRequest" + +components: + parameters: + id: + name: id + in: path + required: true + schema: + type: string + + responses: + NotFound: + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + BadRequest: + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + Health: + type: object + required: [status] + properties: + status: + type: string + example: ok + Error: + type: object + required: [error] + properties: + error: + type: string + Url: + type: object + required: [id, url, label] + properties: + id: + type: string + url: + type: string + format: uri + label: + type: string + description: Tag such as "local" / "external" or a custom label + position: + type: number + UrlInput: + type: object + required: [url] + properties: + url: + type: string + format: uri + label: + type: string + default: "" + Item: + type: object + required: [id, sectionId, kind, name, position, urls] + properties: + id: + type: string + sectionId: + type: string + kind: + type: string + enum: [service, widget] + name: + type: string + icon: + type: string + description: "/api/icons/ path or remote URL; empty = letter tile" + position: + type: number + config: + type: object + nullable: true + additionalProperties: true + description: Widget config (Phase 2); null for services + urls: + type: array + items: + $ref: "#/components/schemas/Url" + ItemInput: + type: object + required: [sectionId, name] + properties: + sectionId: + type: string + kind: + type: string + enum: [service, widget] + default: service + name: + type: string + icon: + type: string + config: + type: object + nullable: true + additionalProperties: true + description: Widget config — keys defined by the widget type's fields + urls: + type: array + items: + $ref: "#/components/schemas/UrlInput" + ItemPatch: + type: object + properties: + name: + type: string + icon: + type: string + config: + type: object + nullable: true + additionalProperties: true + urls: + type: array + description: Full replacement of the item's URL list + items: + $ref: "#/components/schemas/UrlInput" + Section: + type: object + required: [id, name, position, collapsed, items] + properties: + id: + type: string + name: + type: string + position: + type: number + collapsed: + type: boolean + items: + type: array + items: + $ref: "#/components/schemas/Item" + SectionInput: + type: object + required: [name] + properties: + name: + type: string + SectionPatch: + type: object + properties: + name: + type: string + collapsed: + type: boolean + Reorder: + type: object + required: [id] + properties: + id: + type: string + beforeId: + type: string + description: Neighbor now before the moved entity; null if first + afterId: + type: string + description: Neighbor now after the moved entity; null if last + ItemReorder: + allOf: + - $ref: "#/components/schemas/Reorder" + - type: object + properties: + sectionId: + type: string + description: Target section (defaults to current) + Status: + type: object + required: [status] + properties: + status: + type: string + enum: [up, down] + latencyMs: + type: integer + checkedAt: + type: string + format: date-time + WidgetField: + type: object + required: [key, label] + properties: + key: + type: string + label: + type: string + secret: + type: boolean + description: Render as password input; never logged + placeholder: + type: string + required: + type: boolean + WidgetType: + type: object + required: [type, name, fields] + properties: + type: + type: string + name: + type: string + description: + type: string + local: + type: boolean + description: Client-rendered (e.g. clock); no /data fetch needed + fields: + type: array + items: + $ref: "#/components/schemas/WidgetField" + WidgetData: + type: object + required: [data] + properties: + data: + type: object + additionalProperties: true + fetchedAt: + type: string + format: date-time + Export: + type: object + required: [version, sections] + properties: + version: + type: integer + settings: + type: object + additionalProperties: true + sections: + type: array + items: + $ref: "#/components/schemas/Section" diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..1bf7140 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + ignorePatterns: [ + ".agents/**", + ".claude/**", + "tools/oxlint/anti-slop/**", + "packages/api-client/src/schema.d.ts", + "**/dist/**", + "**/node_modules/**", + ], + jsPlugins: [ + { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, + ], + rules: { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + "anti-slop/no-known-value-widening": "error", + }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a3e80b2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4250 @@ +{ + "name": "dash", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dash", + "hasInstallScript": true, + "workspaces": [ + "apps/frontend", + "packages/api-client" + ], + "devDependencies": { + "@oxlint/plugins": "1.78.0", + "oxlint": "^1.78.0" + } + }, + "apps/frontend": { + "name": "@dash/frontend", + "version": "0.0.0", + "dependencies": { + "@dash/api-client": "*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/geist": "^5.2.5", + "@fontsource-variable/geist-mono": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/instrument-serif": "^5.3.0", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-slot": "^1.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.545.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.13", + "@types/node": "^24.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^5.0.4", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2", + "vite": "^7.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dash/api-client": { + "resolved": "packages/api-client", + "link": true + }, + "node_modules/@dash/frontend": { + "resolved": "apps/frontend", + "link": true + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/instrument-serif": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.3.0.tgz", + "integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.78.0.tgz", + "integrity": "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.20", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.20.tgz", + "integrity": "sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.545.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", + "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/openapi-fetch": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.2.tgz", + "integrity": "sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "packages/api-client": { + "name": "@dash/api-client", + "version": "0.0.0", + "dependencies": { + "openapi-fetch": "^0.15.0" + }, + "devDependencies": { + "openapi-typescript": "^7.10.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..124f1f1 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "dash", + "private": true, + "type": "module", + "workspaces": [ + "apps/frontend", + "packages/api-client" + ], + "scripts": { + "generate": "npm run generate -w @dash/api-client", + "dev": "npm run dev -w @dash/frontend", + "build": "npm run build -w @dash/api-client --if-present && npm run build -w @dash/frontend", + "typecheck": "npm run typecheck -w @dash/frontend", + "lint": "oxlint", + "postinstall": "npm run generate" + }, + "devDependencies": { + "@oxlint/plugins": "1.78.0", + "oxlint": "^1.78.0" + } +} diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 0000000..9fd49ae --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@dash/api-client", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/schema.d.ts" + }, + "scripts": { + "generate": "openapi-typescript ../../openapi.yaml -o src/schema.d.ts" + }, + "dependencies": { + "openapi-fetch": "^0.15.0" + }, + "devDependencies": { + "openapi-typescript": "^7.10.1" + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 0000000..556cf39 --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,11 @@ +// Generated-client entrypoint. Types come from src/schema.d.ts, produced by +// `npm run generate` from ../../openapi.yaml — the API contract. +import createFetchClient from "openapi-fetch"; +import type { paths } from "./schema.d.ts"; + +export type { paths, components } from "./schema.d.ts"; + +/** API client bound to the OpenAPI contract. Same-origin by default. */ +export function createClient(baseUrl = "") { + return createFetchClient({ baseUrl }); +} diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..dea7648 --- /dev/null +++ b/prompt.md @@ -0,0 +1 @@ +Okay, I have another simple, quick, easy project that I want to create. I always use some kind of dashboard to manage my home lab and visualize all the stuff that I am running and also what stuff I am running externally. But most of the dashboards seem like they are stuck in the past, which I don't really like. So currently, what I use is CasaOS, which isn't really used for this, but you know, I managed to make it work because when I deploy services, I, let's say, I mostly deploy on CasaOS or Dockploy, but that's not the main point. It's just the CasaOS management and the visualization is so good and easy to use that I like it really much. So here is what I want from the project. I want it stylized and simple, looking like Vercel, in the Vercel style, so ChatCN, styling, GoBackEnd and SolidJS or React frontend, really easy, modern, dark and white mode, nice to look at and easy to manage. I want drag and drop, no authentication, easy plus button to add service. At external service, so you provide the URL, the name, and the link or the file of the image. So you add these to that, it then visualizes it easily from it. Also, when we add the URL for the service, let's allow insertion of multiple URLs. So, for example, the user has local URL for when they are on the local network. Another they have, for example, external, so their domain if they are externally. Then they can save both of these, then mark it as local or external. And then when I click on the dashboard on the service, it shows a pop-up of these two and they can choose, and it takes them to the proper website. So easy like that. Make sure it's really drag and drop, reorient. Also another feature that I'm missing from QOS is grouping, so let's say I have four services, I group them into one section, it then visualizes it, I can close it, open it, reorient the whole section, make it really nice, really easy, really nice visually and the UI, UX. Also, what I want from the app, I just want it to be a lightweight. I open it, see date, time, for example, time zones, and... Let's add some random widget the user can add. For example, let's add Pi-hole, which would be great for the user. AdGuard, Image, for example, if the user are using Image. And I will later send a list of what services exactly I am using, so we can prepare a proper widget structure for other stuff that have APIs and so on. So this should be all of it. Use react and other stuff from @tdvorak-fullstack skills. Drag and drop nice things.. \ No newline at end of file diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 0000000..d750cb5 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,23 @@ +// Vendored from github.com/dmmulroy/anti-slop (MIT), trimmed to the adopted +// rule set in references/architecture.md. Do not add rules here without +// updating the adopted list. +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..0d11852 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..0df39ab --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,415 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionaryValue, + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +import { resolveVariable } from "../shared/scope.ts"; + +import type { ESTree, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function isFunctionExpression(node: ESTree.Node): node is FunctionExpression { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "TSDeclareFunction" || + node.type === "TSEmptyBodyFunctionExpression" + ); +} + +function localFunctionForCall( + sourceCode: SourceCode, + callee: ESTree.Expression, +): FunctionExpression | null { + const unwrapped = unwrapExpression(callee); + if (isFunctionExpression(unwrapped)) return unwrapped; + if (unwrapped.type !== "Identifier") return null; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) { + return definition.node; + } + if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") { + return null; + } + const initializer = definition.node.init; + if (initializer === null) return null; + const unwrappedInitializer = unwrapExpression(initializer); + return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null; +} + +function variableTypeAnnotation( + sourceCode: SourceCode, + variable: Variable, +): ESTree.TSTypeAnnotation | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if ( + definition.type === "Variable" && + definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" + ) { + return definition.node.id.typeAnnotation ?? null; + } + if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) { + return null; + } + const parameter = definition.node.params.find( + (candidate) => + functionParameterBindingName(candidate, sourceCode) === variable.name, + ); + return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null); +} + +function hasInformativeType( + type: ESTree.TSType, + environment: TypeEnvironment, +): boolean { + return classifyUnsafeDictionaryValue(type, environment) === null; +} + +function hasKnownCallArgumentEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + environment: TypeEnvironment, + visitedVariables = new Set(), +): boolean { + if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") { + return hasInformativeType(expression.typeAnnotation, environment); + } + if (expression.type === "TSSatisfiesExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "CallExpression") { + const owner = localFunctionForCall(sourceCode, expression.callee); + const returnType = owner?.returnType?.typeAnnotation; + return returnType !== undefined && hasInformativeType(returnType, environment); + } + if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression); + const variable = resolveVariable(sourceCode, expression); + if (variable === null || visitedVariables.has(variable)) return false; + const annotation = variableTypeAnnotation(sourceCode, variable); + if (annotation !== null) { + return hasInformativeType(annotation.typeAnnotation, environment); + } + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownCallArgumentEvidence( + sourceCode, + declarator.init, + environment, + visitedVariables, + ); +} + +function typePredicateSubjectIndex( + sourceCode: SourceCode, + owner: FunctionExpression, +): number | null { + const predicate = owner.returnType?.typeAnnotation; + if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") { + return null; + } + const predicateParameterName = predicate.parameterName.name; + const index = owner.params.findIndex( + (parameter) => + functionParameterBindingName(parameter, sourceCode) === predicateParameterName, + ); + return index === -1 ? null : index; +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + CallExpression(node) { + if (environment === null) return; + const owner = localFunctionForCall(context.sourceCode, node.callee); + if (owner === null) return; + const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner); + if (parameterIndex === null) return; + const parameter = owner.params[parameterIndex]; + const argument = node.arguments[parameterIndex]; + if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") { + return; + } + const parameterAnnotation = functionParameterTypeAnnotation(parameter); + if ( + parameterAnnotation === null || + parameterAnnotation === undefined || + !containsUnknownType(parameterAnnotation.typeAnnotation) + ) { + return; + } + if ( + !hasKnownCallArgumentEvidence( + context.sourceCode, + argument, + environment, + ) + ) { + return; + } + context.report({ + node: argument, + messageId: "widening", + data: { + subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``, + target: "unknown", + }, + }); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..8cb615a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,154 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; +import { visibleTypeAlias } from "../shared/type-alias-resolution.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return ( + name !== null && + visibleTypeAlias(name, node, environment.typeAliases) !== null && + !isInsideTypeAliasDeclaration(node) + ); +} + +function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean { + let child: ESTree.Node = node; + let parent: ESTree.Node | null = child.parent; + while (parent !== null && parent.type !== "Program") { + if (parent.type === "TSTypeParameter" && parent.constraint === child) return true; + child = parent; + parent = child.parent; + } + return false; +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isInsideTypeParameterConstraint(node)) return false; + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..c5e07f7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..bfea1a2 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,131 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function configuredSafetyMarkers(option: unknown): readonly string[] { + if (typeof option !== "object" || option === null || !("markers" in option)) { + return DEFAULT_SAFETY_MARKERS; + } + const configured = option.markers; + if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS; + const markers = configured.flatMap((marker) => + typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [], + ); + return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS; +} + +function markerPattern(markers: readonly string[]): RegExp { + const alternation = markers + .map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)) + .join("|"); + return new RegExp( + String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`, + "u", + ); +} + +function hasSafetyJustificationBefore( + sourceCode: SourceCode, + owner: ESTree.Node, + assertion: TypeAssertion, + pattern: RegExp, +): boolean { + return sourceCode + .getCommentsBefore(owner) + .some( + (comment) => comment.end <= assertion.start && pattern.test(comment.value), + ); +} + +function hasSafetyComment( + sourceCode: SourceCode, + node: TypeAssertion, + pattern: RegExp, +): boolean { + let current: ESTree.Node = node; + while (true) { + if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true; + if (commentOwnerKinds.has(current.type)) { + const exportDeclaration = current.parent; + return ( + exportDeclaration.type === "ExportNamedDeclaration" && + exportDeclaration.declaration === current && + hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern) + ); + } + if (current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + schema: [ + { + type: "object", + properties: { + markers: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ markers: ["SAFETY"] }], + }, + createOnce(context) { + const patterns = new Map(); + + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node)) return; + const markers = configuredSafetyMarkers(context.options?.[0]); + const patternKey = markers.join("\u0000"); + const pattern = patterns.get(patternKey) ?? markerPattern(markers); + patterns.set(patternKey, pattern); + if (hasSafetyComment(context.sourceCode, node, pattern)) return; + context.report({ + node, + messageId: "missingSafetyComment", + data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] }, + }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000..8db73ff --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,515 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + hasVisibleTypeBinding, + visibleTypeAlias, + type TypeAliasEnvironment as LexicalTypeAliasEnvironment, +} from "./type-alias-resolution.ts"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly interfaces: ReadonlyMap; + readonly typeAliases: LexicalTypeAliasEnvironment; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment( + program: ESTree.Program, + visitorKeys: Readonly>, +): TypeEnvironment { + const interfaces = new Map(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type !== "TSInterfaceDeclaration") continue; + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + } + + return { + interfaces, + typeAliases: createTypeAliasEnvironment(program, visitorKeys), + }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn( + name: string, + use: ESTree.Node, + environment: TypeEnvironment, +): boolean { + return ( + BUILT_INS.has(name) && + !hasVisibleTypeBinding(name, use, environment.typeAliases) + ); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ( + (name === "Pick" || name === "Omit") && + isBuiltIn(name, unwrapped, environment) + ) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, new Map()) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + const resolved = + substitutions === null + ? null + : classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function hasBroadRecordKey( + type: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const key = type.typeArguments?.params[0]; + return key === undefined || isBroadMappedKey(key, environment, substitutions); +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + visitedAliases: ReadonlySet = new Set(), +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some((member) => + isBroadMappedKey(member, environment, substitutions, visitedAliases), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions, visitedAliases); + } + if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true; + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if ( + alias === null || + (alias.typeParameters?.params.length ?? 0) > 0 || + visitedAliases.has(name) + ) { + return false; + } + const nextVisited = new Set(visitedAliases); + nextVisited.add(name); + return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/tools/oxlint/anti-slop/shared/function-parameters.ts b/tools/oxlint/anti-slop/shared/function-parameters.ts new file mode 100644 index 0000000..80de91d --- /dev/null +++ b/tools/oxlint/anti-slop/shared/function-parameters.ts @@ -0,0 +1,49 @@ +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +export type FunctionParameter = ESTree.ParamPattern; + +/** Return whether a type is or contains TypeScript's absorbing unknown top type. */ +export function containsUnknownType(type: ESTree.TSType): boolean { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation); + return type.type === "TSUnionType" && type.types.some(containsUnknownType); +} + +/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */ +export function functionParameterTypeAnnotation( + parameter: FunctionParameter, +): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return functionParameterTypeAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left); + } + return parameter.typeAnnotation; +} + +/** Return only a function parameter's local binding, excluding its annotation and default value. */ +export function functionParameterBindingName( + parameter: FunctionParameter, + sourceCode: SourceCode, +): string { + if (parameter.type === "TSParameterProperty") { + return functionParameterBindingName(parameter.parameter, sourceCode); + } + if (parameter.type === "AssignmentPattern") { + return functionParameterBindingName(parameter.left, sourceCode); + } + if (parameter.type === "RestElement") { + return functionParameterBindingName(parameter.argument, sourceCode); + } + if (parameter.type === "Identifier") return parameter.name; + + const sourceText = sourceCode.getText(parameter); + const annotationStart = parameter.typeAnnotation?.start; + return annotationStart === undefined + ? sourceText + : sourceText.slice(0, annotationStart - parameter.start).trimEnd(); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 0000000..7cdb18c --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/scope.ts b/tools/oxlint/anti-slop/shared/scope.ts new file mode 100644 index 0000000..602414a --- /dev/null +++ b/tools/oxlint/anti-slop/shared/scope.ts @@ -0,0 +1,15 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +/** Resolve an identifier to its binding by walking lexical scopes upward. */ +export function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} diff --git a/tools/oxlint/anti-slop/shared/type-alias-resolution.ts b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts new file mode 100644 index 0000000..4744222 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts @@ -0,0 +1,250 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts"; + +type VisitorKeys = Readonly>; +type TypeScope = ESTree.Node; + +type TypeBinding = { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; + readonly scope: TypeScope; +}; + +type Substitution = { + readonly substitutions: Substitutions; + readonly type: ESTree.TSType; +}; + +type Substitutions = ReadonlyMap; + +export type TypeAliasEnvironment = { + readonly aliases: readonly ESTree.TSTypeAliasDeclaration[]; + readonly bindingsByName: ReadonlyMap; + readonly visitorKeys: VisitorKeys; +}; + +export type ResolvedTypeMatcher = ( + type: ESTree.TSType, + matches: (child: ESTree.TSType) => boolean, +) => boolean; + +const environmentsByProgram = new WeakMap(); + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function enclosingTypeScope(node: ESTree.Node): TypeScope { + let current: ESTree.Node | null = node.parent; + while (current !== null) { + if ( + current.type === "Program" || + current.type === "BlockStatement" || + current.type === "TSModuleBlock" || + current.type === "StaticBlock" || + current.type === "SwitchStatement" + ) { + return current; + } + current = current.parent; + } + return node; +} + +function declaredTypeBinding(node: ESTree.Node): { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; +} | null { + if (node.type === "TSTypeAliasDeclaration") { + return { alias: node, name: node.id.name }; + } + if ( + node.type === "TSInterfaceDeclaration" || + node.type === "TSEnumDeclaration" || + node.type === "ClassDeclaration" || + node.type === "ClassExpression" + ) { + return node.id === null ? null : { alias: null, name: node.id.name }; + } + if ( + node.type === "ImportSpecifier" || + node.type === "ImportDefaultSpecifier" || + node.type === "ImportNamespaceSpecifier" + ) { + return { alias: null, name: node.local.name }; + } + return null; +} + +function collectTypeBindings( + node: ESTree.Node, + visitorKeys: VisitorKeys, + bindingsByName: Map, + aliases: ESTree.TSTypeAliasDeclaration[], +): void { + const declared = declaredTypeBinding(node); + if (declared !== null) { + const bindings = bindingsByName.get(declared.name) ?? []; + bindings.push({ ...declared, scope: enclosingTypeScope(node) }); + bindingsByName.set(declared.name, bindings); + if (declared.alias !== null) aliases.push(declared.alias); + } + + // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties. + const fields = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = fields[key]; + if (isNode(value)) { + collectTypeBindings(value, visitorKeys, bindingsByName, aliases); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) { + collectTypeBindings(child, visitorKeys, bindingsByName, aliases); + } + } + } +} + +/** Collect every lexical type alias and competing type binding in a program. */ +export function createTypeAliasEnvironment( + program: ESTree.Program, + visitorKeys: VisitorKeys, +): TypeAliasEnvironment { + const cached = environmentsByProgram.get(program); + if (cached !== undefined) return cached; + const bindingsByName = new Map(); + const aliases: ESTree.TSTypeAliasDeclaration[] = []; + collectTypeBindings(program, visitorKeys, bindingsByName, aliases); + const environment = { aliases, bindingsByName, visitorKeys }; + environmentsByProgram.set(program, environment); + return environment; +} + +function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null { + let current: ESTree.Node | null = node; + let distance = 0; + while (current !== null) { + if (current === ancestor) return distance; + current = current.parent; + distance += 1; + } + return null; +} + +function nearestTypeBindings( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): readonly TypeBinding[] { + const candidates = environment.bindingsByName.get(name) ?? []; + let nearestDistance = Number.POSITIVE_INFINITY; + let nearest: TypeBinding[] = []; + for (const candidate of candidates) { + const distance = ancestorDistance(candidate.scope, use); + if (distance === null || distance > nearestDistance) continue; + if (distance === nearestDistance) { + nearest.push(candidate); + continue; + } + nearestDistance = distance; + nearest = [candidate]; + } + return nearest; +} + +/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */ +export function visibleTypeAlias( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): ESTree.TSTypeAliasDeclaration | null { + if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null; + const bindings = nearestTypeBindings(name, use, environment); + return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null; +} + +/** Return whether a local declaration shadows a built-in type at this use. */ +export function hasVisibleTypeBinding( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): boolean { + return ( + lexicalTypeParameterNames(use, environment.visitorKeys).has(name) || + nearestTypeBindings(name, use, environment).length > 0 + ); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function aliasSubstitutions( + alias: ESTree.TSTypeAliasDeclaration, + reference: ESTree.TSTypeReference, + base: Substitutions, +): Substitutions | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = reference.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const explicitArgument = arguments_[index]; + const argument = explicitArgument ?? parameter.default; + if (argument === null || argument === undefined) return null; + const argumentSubstitutions = explicitArgument === undefined ? next : base; + next.set(parameter.name.name, { + type: argument, + substitutions: new Map(argumentSubstitutions), + }); + } + return next; +} + +/** Match a type after resolving visible aliases and substituting their type parameters. */ +export function resolvedTypeMatches( + type: ESTree.TSType, + environment: TypeAliasEnvironment, + matcher: ResolvedTypeMatcher, +): boolean { + const evaluate = ( + current: ESTree.TSType, + substitutions: Substitutions, + resolvingAliases: ReadonlySet, + ): boolean => { + if (current.type === "TSTypeReference") { + const name = typeReferenceName(current); + if (name !== null) { + const substitution = substitutions.get(name); + if (substitution !== undefined && !current.typeArguments?.params.length) { + return evaluate( + substitution.type, + substitution.substitutions, + resolvingAliases, + ); + } + const alias = visibleTypeAlias(name, current, environment); + if (alias !== null && !resolvingAliases.has(alias)) { + const nextSubstitutions = aliasSubstitutions(alias, current, substitutions); + if (nextSubstitutions !== null) { + const nextResolving = new Set(resolvingAliases); + nextResolving.add(alias); + return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving); + } + } + } + } + return matcher(current, (child) => + evaluate(child, substitutions, resolvingAliases), + ); + }; + + return evaluate(type, new Map(), new Set()); +}