mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
Merge pull request #1 from Dvorinka/feat/initial-dashboard
feat: initial dashboard — phases 0–3
This commit is contained in:
92 files changed
+14315
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
data
|
||||||
|
docs
|
||||||
|
mockups
|
||||||
|
tools
|
||||||
|
.github
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -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
|
||||||
+25
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
+142
@@ -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/<name>/{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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <itemID><ext> and
|
||||||
|
// points the item's icon field at /api/icons/<file>.
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Whitespace-only changes.
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dash</title>
|
||||||
|
<script>
|
||||||
|
// Pre-paint theme: localStorage mirrors the server setting, applied
|
||||||
|
// before first paint so there is no flash of the wrong theme.
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("dash.theme");
|
||||||
|
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/assets/index-LtBmkZex.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-CoYED22n.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pi-hole v5 HTTP API: /admin/api.php?summaryRaw&auth=<token>.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dash</title>
|
||||||
|
<script>
|
||||||
|
// Pre-paint theme: localStorage mirrors the server setting, applied
|
||||||
|
// before first paint so there is no flash of the wrong theme.
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("dash.theme");
|
||||||
|
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<BoardProvider>
|
||||||
|
<Shell />
|
||||||
|
</BoardProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Item | undefined>();
|
||||||
|
|
||||||
|
// ⌘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 (
|
||||||
|
<div className="font-sans">
|
||||||
|
<header className="sticky top-0 z-10 flex items-center gap-3.5 border-b border-border bg-bg/80 px-7 py-3.5 backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2.5 text-[15px] font-semibold tracking-tight">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor" />
|
||||||
|
<rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
|
||||||
|
<rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
|
||||||
|
<rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
Dash
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline" aria-label="Search (⌘K)"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
className="gap-1.5 text-text-faint"
|
||||||
|
>
|
||||||
|
<Search size={14} strokeWidth={1.8} />
|
||||||
|
<kbd className="hidden font-mono text-[10px] min-[620px]:inline">⌘K</kbd>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="icon" aria-label="Settings"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings size={15} strokeWidth={1.8} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="icon" aria-label="Toggle theme"
|
||||||
|
onClick={() => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
{board.theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" aria-label="Add widget"
|
||||||
|
onClick={() => { setEditing(undefined); setWdgOpen(true); }}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={13} strokeWidth={2} />
|
||||||
|
Widget
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setEditing(undefined); setSvcOpen(true); }}>
|
||||||
|
<Plus size={13} strokeWidth={2.2} />
|
||||||
|
Add service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Board onEditItem={openEditor} />
|
||||||
|
|
||||||
|
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||||
|
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||||
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onOpenChange={setPaletteOpen}
|
||||||
|
onAddService={() => { setEditing(undefined); setSvcOpen(true); }}
|
||||||
|
onAddWidget={() => { setEditing(undefined); setWdgOpen(true); }}
|
||||||
|
onSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createClient } from "@dash/api-client";
|
||||||
|
|
||||||
|
/** Same-origin typed client; vite proxies /api in dev. */
|
||||||
|
export const api = createClient();
|
||||||
@@ -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 <main className="mx-auto max-w-[1140px] px-7 pb-24 pt-9" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (board.sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex max-w-[1140px] flex-col items-center gap-4 px-7 pb-24 pt-24">
|
||||||
|
<p className="text-[13px] text-text-faint">No sections yet.</p>
|
||||||
|
<AddSectionRow
|
||||||
|
open={addingSection} setOpen={setAddingSection}
|
||||||
|
name={sectionName} setName={setSectionName} onSubmit={submitSection}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-[1140px] px-7 pb-24 pt-9">
|
||||||
|
<BoardDnd>
|
||||||
|
<SectionSortList ids={board.sections.map((s) => s.id)}>
|
||||||
|
<views.BoardView>
|
||||||
|
{board.sections.map((sec, si) => (
|
||||||
|
<views.SectionView
|
||||||
|
key={sec.id}
|
||||||
|
section={sec}
|
||||||
|
index={si}
|
||||||
|
onToggle={() => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemSortZone section={sec} className={views.itemsGridClass}>
|
||||||
|
{sec.items.map((it, ii) =>
|
||||||
|
it.kind === "widget" ? (
|
||||||
|
<views.WidgetView key={it.id} item={it} index={ii} sectionId={sec.id} onEdit={() => onEditItem(it)} />
|
||||||
|
) : (
|
||||||
|
<views.ItemView
|
||||||
|
key={it.id}
|
||||||
|
item={it}
|
||||||
|
index={ii}
|
||||||
|
sectionId={sec.id}
|
||||||
|
status={board.statuses[it.id]}
|
||||||
|
onEdit={() => onEditItem(it)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ItemSortZone>
|
||||||
|
</views.SectionView>
|
||||||
|
))}
|
||||||
|
</views.BoardView>
|
||||||
|
</SectionSortList>
|
||||||
|
</BoardDnd>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<AddSectionRow
|
||||||
|
open={addingSection} setOpen={setAddingSection}
|
||||||
|
name={sectionName} setName={setSectionName} onSubmit={submitSection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={12} /> Add section
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Section name"
|
||||||
|
className="w-[200px]"
|
||||||
|
onBlur={() => !name.trim() && setOpen(false)}
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">Add</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={collision}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragCancel={onDragCancel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeLabel ? (
|
||||||
|
<div className="rounded-[10px] border border-border-strong bg-popover px-4 py-2.5 text-[13px] font-medium shadow-xl">
|
||||||
|
{activeLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sortable context for the section list (vertical). Shared, not per-renderer. */
|
||||||
|
export function SectionSortList({ ids, children }: { ids: string[]; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
{children}
|
||||||
|
</SortableContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<SortableContext items={section.items.map((i) => i.id)} strategy={rectSortingStrategy}>
|
||||||
|
<div ref={setNodeRef} className={className} style={{ minHeight: 8 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn("relative flex shrink-0 items-center justify-center overflow-hidden bg-icon-bg text-text-dim font-semibold", className)}
|
||||||
|
style={{ width: size, height: size, borderRadius: Math.round(size * 0.225), fontSize: size * 0.375 }}
|
||||||
|
>
|
||||||
|
{!item.icon || failed ? (
|
||||||
|
letter
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={item.icon}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full bg-icon-bg object-contain p-[15%]"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 shrink-0 rounded-full",
|
||||||
|
state === "up" ? "bg-up" : state === "down" ? "bg-down" : "bg-text-faint/40",
|
||||||
|
)}
|
||||||
|
title={state ?? "unknown"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto flex shrink-0 items-center gap-1.5 rounded-[5px] border border-border px-[7px] py-[3px]",
|
||||||
|
"font-mono text-[9.5px] tracking-[0.06em] text-text-faint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("size-[5px] rounded-full", state === "up" ? "bg-up" : state === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||||
|
<span className="max-[620px]:hidden">{state ?? "…"}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverAnchor asChild>{children(activate)}</PopoverAnchor>
|
||||||
|
<PopoverContent onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
{urls.map((u) => (
|
||||||
|
<a
|
||||||
|
key={u.id}
|
||||||
|
href={u.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => 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 ? (
|
||||||
|
<span className="shrink-0 rounded bg-icon-bg px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.08em] text-text-dim">
|
||||||
|
{u.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto truncate font-mono text-[11.5px] text-text-faint">{hostOf(u.url)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
addSection(name: string): Promise<Section | null>;
|
||||||
|
patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise<void>;
|
||||||
|
deleteSection(id: string): Promise<void>;
|
||||||
|
addItem(input: ItemInput): Promise<Item | null>;
|
||||||
|
patchItem(id: string, patch: ItemPatch): Promise<void>;
|
||||||
|
deleteItem(id: string): Promise<void>;
|
||||||
|
uploadIcon(id: string, file: File): Promise<void>;
|
||||||
|
moveItemLocal(itemId: string, toSectionId: string, index: number): void;
|
||||||
|
moveSectionLocal(id: string, index: number): void;
|
||||||
|
persistItemMove(itemId: string, sectionId: string, beforeId?: string, afterId?: string): Promise<void>;
|
||||||
|
persistSectionMove(id: string, beforeId?: string, afterId?: string): Promise<void>;
|
||||||
|
setSetting(key: keyof Prefs, value: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<BoardState | null>(null);
|
||||||
|
|
||||||
|
export function BoardProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [sections, setSections] = useState<Section[]>([]);
|
||||||
|
const [statuses, setStatuses] = useState<StatusMap>({});
|
||||||
|
const [prefs, setPrefs] = useState<Prefs>({ 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: <html data-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<BoardState>(() => ({
|
||||||
|
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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoard(): BoardState {
|
||||||
|
const v = useContext(Ctx);
|
||||||
|
if (!v) throw new Error("useBoard outside BoardProvider");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const commands = useMemo<Command[]>(() => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="top-[18%] max-w-[520px] translate-y-0 gap-0 overflow-hidden p-0" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-border px-4">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-faint">esc</kbd>
|
||||||
|
</div>
|
||||||
|
<div ref={listRef} className="max-h-[340px] overflow-y-auto p-1.5">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">No matches.</p>
|
||||||
|
) : groups.map((g) => (
|
||||||
|
<div key={g.name}>
|
||||||
|
<div className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
{g.name}
|
||||||
|
</div>
|
||||||
|
{g.items.map(({ c, i }) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
data-idx={i}
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => pick(i)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2.5 rounded-[7px] px-2.5 py-2 text-left text-[13px]",
|
||||||
|
i === clamped && "bg-surface-hover",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.icon ? (
|
||||||
|
<IconImg item={c.icon} size={22} />
|
||||||
|
) : c.glyph ? (
|
||||||
|
<span className="flex size-[22px] items-center justify-center text-text-faint">
|
||||||
|
<c.glyph size={13} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate">{c.label}</span>
|
||||||
|
{c.hint ? <span className="ml-auto truncate font-mono text-[10.5px] text-text-faint">{c.hint}</span> : null}
|
||||||
|
{i === clamped ? <ArrowRight size={12} className="shrink-0 text-text-faint" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/<slug>.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<UrlInput[]>([{ url: "", label: "local" }]);
|
||||||
|
const [sectionId, setSectionId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const fileRef = useRef<HTMLInputElement>(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<UrlInput>) =>
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit service" : "Add service"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-[1fr_150px] gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="svc-name">Name</Label>
|
||||||
|
<Input id="svc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
|
||||||
|
</div>
|
||||||
|
{!editing && board.sections.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Section</Label>
|
||||||
|
<Select value={sectionId} onValueChange={setSectionId}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{board.sections.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>URLs</Label>
|
||||||
|
{urls.map((u, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={u.url}
|
||||||
|
onChange={(e) => setUrl(i, { url: e.target.value })}
|
||||||
|
placeholder="https://…"
|
||||||
|
className="font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={u.label ?? ""}
|
||||||
|
onChange={(e) => setUrl(i, { label: e.target.value })}
|
||||||
|
placeholder="label"
|
||||||
|
className="w-[110px] shrink-0"
|
||||||
|
list="url-labels"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon"
|
||||||
|
aria-label="Remove URL"
|
||||||
|
disabled={urls.length <= 1}
|
||||||
|
onClick={() => setUrls((us) => us.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<datalist id="url-labels">
|
||||||
|
<option value="local" /><option value="external" />
|
||||||
|
</datalist>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="sm"
|
||||||
|
className="mt-1 w-fit"
|
||||||
|
onClick={() => setUrls((us) => [...us, { url: "", label: "" }])}
|
||||||
|
>
|
||||||
|
<Plus size={12} /> Add URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="svc-icon">Icon URL</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconPreview url={icon} />
|
||||||
|
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
|
||||||
|
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
|
||||||
|
<Upload size={13} />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileRef} type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={() => setErr("")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!icon && diSlug(name) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIcon(`${DI_BASE}/${diSlug(name)}.png`)}
|
||||||
|
className="w-fit rounded border border-border px-1.5 py-0.5 font-mono text-[10.5px] text-text-faint transition-colors hover:border-border-strong hover:text-text"
|
||||||
|
>
|
||||||
|
suggest: {diSlug(name)}.png
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="items-center">
|
||||||
|
{editing ? (
|
||||||
|
<Button type="button" variant="destructive" size="sm" className="mr-auto" onClick={() => void remove()} disabled={busy}>
|
||||||
|
<Trash2 size={12} /> Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||||
|
{busy ? "Saving…" : editing ? "Save" : "Add service"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <span className="size-[30px] shrink-0 rounded-[7px] border border-dashed border-border" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className="size-[30px] shrink-0 rounded-[7px] border border-border bg-icon-bg object-contain p-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Settings</DialogTitle>
|
||||||
|
<DialogDescription>Board layout, theme, and backup.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Renderer</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label="Renderer">
|
||||||
|
{rendererList.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={board.renderer === r}
|
||||||
|
onClick={() => void board.setSetting("renderer", r)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[8px] border px-3 py-2.5 text-left transition-colors",
|
||||||
|
board.renderer === r
|
||||||
|
? "border-border-strong bg-surface-hover"
|
||||||
|
: "border-border hover:border-border-strong hover:bg-surface-hover",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-[12.5px] font-medium">{rendererLabels[r]}</span>
|
||||||
|
<span className="block font-mono text-[10px] text-text-faint">{rendererDescriptions[r]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Theme</Label>
|
||||||
|
<Select value={board.theme} onValueChange={(v) => {
|
||||||
|
if (v === "dark" || v === "light") void board.setSetting("theme", v);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||||
|
<Download size={12} /> Export JSON
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||||
|
<Upload size={12} /> Import
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileRef} type="file" accept="application/json" className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void importJson(f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<WidgetType[]>([]);
|
||||||
|
const [type, setType] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [sectionId, setSectionId] = useState("");
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
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<WidgetType[]>)
|
||||||
|
.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<string, string> = {};
|
||||||
|
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<Item["config"]> = { 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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit widget" : "Add widget"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editing ? "Update widget settings." : "Pick a type and point it at your service."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-[1fr_150px] gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="wdg-name">Name</Label>
|
||||||
|
<Input id="wdg-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
|
||||||
|
</div>
|
||||||
|
{!editing && board.sections.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Section</Label>
|
||||||
|
<Select value={sectionId} onValueChange={setSectionId}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{board.sections.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{types.map((t) => (
|
||||||
|
<SelectItem key={t.type} value={t.type}>{t.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{meta?.description ? (
|
||||||
|
<p className="text-[11px] text-text-faint">{meta.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta?.fields.map((f) => (
|
||||||
|
<div key={f.key} className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={`wdg-${f.key}`}>
|
||||||
|
{f.label}{f.required ? "" : " (optional)"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`wdg-${f.key}`}
|
||||||
|
type={f.secret ? "password" : "text"}
|
||||||
|
value={values[f.key] ?? ""}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||||
|
className="font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="items-center">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||||
|
{busy ? "Saving…" : editing ? "Save" : "Add widget"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -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<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/60 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2",
|
||||||
|
"rounded-xl border border-border-strong bg-popover p-5 text-text shadow-2xl",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton ? (
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||||
|
<X size={14} />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
) : null}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("mb-4 flex flex-col gap-1", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
className={cn("text-[15px] font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
className={cn("text-[13px] text-text-dim", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("mt-5 flex justify-end gap-2", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-border bg-surface px-3 text-[13px] text-text",
|
||||||
|
"placeholder:text-text-faint focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -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<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
className={cn("text-[12px] font-medium text-text-dim select-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -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<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-30 min-w-[235px] rounded-[10px] border border-border-strong bg-popover p-[5px] text-text",
|
||||||
|
"shadow-[0_8px_30px_rgba(0,0,0,0.35),0_1px_3px_rgba(0,0,0,0.2)]",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.98]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent };
|
||||||
@@ -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<typeof SelectPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-md border border-border bg-surface px-3",
|
||||||
|
"text-[13px] text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
"[&>span]:truncate",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown size={13} className="text-text-faint" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
position="popper"
|
||||||
|
sideOffset={6}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[10px]",
|
||||||
|
"border border-border-strong bg-popover p-[5px] text-text shadow-xl",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.98]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport>{children}</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-3 text-[13px]",
|
||||||
|
"outline-none data-[highlighted]:bg-surface-hover",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex items-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check size={13} />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -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 <div className="flex flex-col gap-5">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
|
||||||
|
const dnd = useSectionDnd(section.id);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
className={cn(dnd.isDragging && "opacity-35")}
|
||||||
|
>
|
||||||
|
<div className="group/sec flex cursor-pointer select-none items-center gap-3 px-0.5 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag section"
|
||||||
|
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
|
>
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-dim">
|
||||||
|
{section.name}
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint">
|
||||||
|
{String(section.items.length).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
className={cn("text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete section"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!section.collapsed && children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
const host = item.urls[0] ? hostOf(item.urls[0].url) : "";
|
||||||
|
return (
|
||||||
|
<ItemAnchor item={item} onEdit={onEdit}>
|
||||||
|
{(open) => (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
"group/tile relative col-span-1 flex min-h-[70px] cursor-grab items-center gap-3",
|
||||||
|
"rounded-[10px] border border-border bg-surface px-[15px] py-[13px]",
|
||||||
|
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||||
|
"min-[620px]:col-span-2",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconImg item={item} size={40} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-medium tracking-tight">{item.name}</div>
|
||||||
|
{host ? <div className="truncate font-mono text-[10.5px] text-text-faint">{host}</div> : null}
|
||||||
|
</div>
|
||||||
|
{item.urls.length > 0 && <StatusChip status={status} />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-2 rounded p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/tile:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
className={cn(
|
||||||
|
"group/tile relative col-span-2 flex min-h-[70px] cursor-grab items-center gap-3",
|
||||||
|
"rounded-[10px] border border-border bg-surface px-[15px] py-[13px]",
|
||||||
|
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||||
|
"min-[620px]:col-span-2 min-[900px]:col-span-3",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<WidgetContent item={item} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-2 rounded p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/tile:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
@@ -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 <div className="flex flex-col gap-10">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
|
||||||
|
const dnd = useSectionDnd(section.id);
|
||||||
|
return (
|
||||||
|
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-40")}>
|
||||||
|
<div className="group/sec mb-3.5 flex cursor-pointer select-none items-center gap-2.5 px-1 py-1.5 text-text-dim">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||||
|
>
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em]">{section.name}</h2>
|
||||||
|
<span className="text-[11px] tabular-nums text-text-faint">{section.items.length}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag section"
|
||||||
|
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete section"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
className={cn("text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!section.collapsed && children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
const host = item.urls[0] ? hostOf(item.urls[0].url) : "";
|
||||||
|
return (
|
||||||
|
<ItemAnchor item={item} onEdit={onEdit}>
|
||||||
|
{(open) => (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
"group/card relative flex cursor-grab items-center gap-3",
|
||||||
|
"rounded-[10px] border border-border bg-surface px-3.5 py-3",
|
||||||
|
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||||
|
dnd.isDragging && "opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconImg item={item} size={36} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-medium tracking-tight">{item.name}</div>
|
||||||
|
{host ? <div className="truncate text-[11.5px] text-text-faint">{host}</div> : null}
|
||||||
|
</div>
|
||||||
|
{item.urls.length > 1 && (
|
||||||
|
<span className="shrink-0 rounded border border-border px-1 py-px text-[10px] tabular-nums text-text-faint">
|
||||||
|
{item.urls.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.urls.length > 0 && <StatusChip status={status} variant="dot" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute -right-1 -top-1 rounded-full border border-border bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/card:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetView({ item, sectionId, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
className={cn(
|
||||||
|
"group/card relative col-span-2 flex cursor-grab items-center gap-3",
|
||||||
|
"rounded-[10px] border border-border bg-surface px-3.5 py-3",
|
||||||
|
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||||
|
dnd.isDragging && "opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<WidgetContent item={item} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute -right-1 -top-1 rounded-full border border-border bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/card:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cards: RendererViews = {
|
||||||
|
BoardView,
|
||||||
|
SectionView,
|
||||||
|
ItemView,
|
||||||
|
WidgetView,
|
||||||
|
itemsGridClass: "grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5",
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<header className="mb-7 flex items-end justify-between gap-6 border-b border-border-strong pb-5">
|
||||||
|
<h1 className="font-serif text-[52px] leading-none tracking-tight max-[620px]:text-[38px]">
|
||||||
|
Dash.
|
||||||
|
</h1>
|
||||||
|
<span className="pb-1 text-right font-mono text-[11px] uppercase tracking-[0.08em] text-text-faint">
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-10 grid grid-cols-2 gap-px overflow-hidden rounded-[10px] border border-border bg-border min-[760px]:grid-cols-5">
|
||||||
|
{cells.map(([k, v, s]) => (
|
||||||
|
<div key={k} className="bg-surface px-4 py-3.5">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
|
||||||
|
<div className="mt-1 font-mono text-[24px] font-medium leading-none tracking-tight tabular-nums">{v}</div>
|
||||||
|
<div className="mt-1.5 truncate text-[10.5px] text-text-faint">{s}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-9">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionView({ section, index, onToggle, onDelete, children }: SectionViewProps) {
|
||||||
|
const dnd = useSectionDnd(section.id);
|
||||||
|
return (
|
||||||
|
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-35")}>
|
||||||
|
<div className="group/sec mb-0.5 flex cursor-pointer select-none items-baseline gap-3.5 border-b border-border-strong pb-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag section"
|
||||||
|
className="cursor-grab self-center text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-baseline gap-3.5 text-left"
|
||||||
|
>
|
||||||
|
<span className="font-serif text-[30px] leading-none text-text-faint tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<h2 className="truncate font-serif text-[30px] leading-none tracking-tight">
|
||||||
|
{section.name}
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint">
|
||||||
|
{section.items.length} {section.items.length === 1 ? "entry" : "entries"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={cn("ml-auto self-center text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete section"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="self-center text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!section.collapsed && children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ItemAnchor item={item} onEdit={onEdit}>
|
||||||
|
{(open) => (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[26px_30px_minmax(0,1fr)_64px] items-center gap-3.5",
|
||||||
|
"border-b border-border px-1.5 py-[11px] transition-colors hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[30px_34px_minmax(0,1.4fr)_minmax(0,1fr)_auto_70px_22px]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<IconImg item={item} size={30} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-[14px] font-medium tracking-tight">{item.name}</span>
|
||||||
|
{host ? <span className="block truncate font-mono text-[10.5px] text-text-faint min-[760px]:hidden">{host}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="hidden min-w-0 truncate font-mono text-[11px] text-text-faint min-[760px]:block">{host}</span>
|
||||||
|
<span className="hidden justify-end gap-1.5 min-[760px]:flex">
|
||||||
|
{item.urls.slice(0, 2).map((u) => u.label ? (
|
||||||
|
<span key={u.id} className="rounded border border-border px-[5px] py-0.5 font-mono text-[9px] uppercase tracking-[0.08em] text-text-faint">
|
||||||
|
{u.label}
|
||||||
|
</span>
|
||||||
|
) : null)}
|
||||||
|
</span>
|
||||||
|
{item.urls.length > 0 ? (
|
||||||
|
<span className="flex items-center gap-1.5 justify-self-end font-mono text-[10.5px] tracking-[0.05em] text-text-dim transition-opacity group-hover/row:opacity-0">
|
||||||
|
<span className={cn("size-[5px] rounded-full", state === "up" ? "bg-up" : state === "down" ? "bg-down" : "bg-text-faint/40")} />
|
||||||
|
{state ?? "…"}
|
||||||
|
</span>
|
||||||
|
) : <span />}
|
||||||
|
<span className="hidden justify-self-end text-text-faint opacity-0 transition-all group-hover/row:opacity-100 min-[760px]:block">→</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetView({ item, index, sectionId, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[26px_30px_minmax(0,1fr)] items-center gap-3.5",
|
||||||
|
"border-b border-border px-1.5 py-[11px] transition-colors hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[30px_34px_minmax(0,0.8fr)_minmax(0,1.2fr)]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] text-text-faint tabular-nums">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<IconImg item={item} size={30} />
|
||||||
|
<span className="min-w-0 truncate text-[14px] font-medium tracking-tight">{item.name}</span>
|
||||||
|
<div className="min-w-0 max-[759px]:col-start-3">
|
||||||
|
<WidgetContent item={item} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorial: RendererViews = {
|
||||||
|
BoardView,
|
||||||
|
SectionView,
|
||||||
|
ItemView,
|
||||||
|
WidgetView,
|
||||||
|
itemsGridClass: "flex flex-col",
|
||||||
|
};
|
||||||
@@ -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<RendererName, RendererViews> = {
|
||||||
|
bento,
|
||||||
|
cards,
|
||||||
|
index: editorial,
|
||||||
|
console: terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rendererLabels: Record<RendererName, string> = {
|
||||||
|
bento: "Bento",
|
||||||
|
cards: "Cards",
|
||||||
|
index: "Index",
|
||||||
|
console: "Console",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rendererDescriptions: Record<RendererName, string> = {
|
||||||
|
bento: "mixed-size tile grid",
|
||||||
|
cards: "uniform compact cards",
|
||||||
|
index: "editorial list · masthead",
|
||||||
|
console: "terminal panels · mono",
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="font-terminal">
|
||||||
|
<div className="mb-6 whitespace-pre-wrap rounded-md border border-border px-4 py-3.5 text-[11px] leading-[1.7] text-text-faint">
|
||||||
|
{`$ dash status --all\n`}
|
||||||
|
<span className="font-medium text-text-dim">{services.length} services</span>
|
||||||
|
{` indexed · `}
|
||||||
|
<span className="text-up">{up} up</span>
|
||||||
|
{down > 0 ? <span>{` · `}<span className="text-down">{down} down</span></span> : ` · all green`}
|
||||||
|
{` · `}
|
||||||
|
<span className="font-medium text-text-dim">{widgets.length} widgets</span>
|
||||||
|
{`\ndrag rows to reorder · click rows with multiple endpoints to choose`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-2.5 min-[760px]:grid-cols-4">
|
||||||
|
{panels.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.head}
|
||||||
|
className={cn("overflow-hidden rounded-md border border-border bg-surface", p.wide && "col-span-2")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2 text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
{p.head}
|
||||||
|
{p.live ? <span className="ml-auto text-[9px] text-up">● live</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3">
|
||||||
|
<div className={cn("font-medium leading-none tracking-tight tabular-nums", p.wide ? "text-[26px]" : "text-[22px]")}>{p.v}</div>
|
||||||
|
<div className="mt-1 truncate text-[10.5px] text-text-faint">{p.s}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
|
||||||
|
const dnd = useSectionDnd(section.id);
|
||||||
|
return (
|
||||||
|
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-35")}>
|
||||||
|
<div className="group/sec mb-2 flex cursor-pointer select-none items-center gap-2.5 px-0.5 py-1 text-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-text-faint">##</span>
|
||||||
|
<h2 className="truncate font-bold lowercase tracking-[0.02em]">{section.name}</h2>
|
||||||
|
<span className="text-[11px] text-text-faint">[{section.items.length}]</span>
|
||||||
|
<span className="flex-1 overflow-hidden whitespace-nowrap text-text-faint/60" aria-hidden>
|
||||||
|
{"─".repeat(120)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className={cn("text-text-faint transition-transform duration-150", section.collapsed && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag section"
|
||||||
|
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
>
|
||||||
|
≡
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete section"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!section.collapsed && (
|
||||||
|
<div className="overflow-hidden rounded-md border border-border bg-surface">{children}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ItemAnchor item={item} onEdit={onEdit}>
|
||||||
|
{(open) => (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[16px_24px_minmax(0,1fr)_auto] items-center gap-3",
|
||||||
|
"border-b border-border px-3 py-2 text-[12.5px] transition-colors last:border-b-0 hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[20px_26px_minmax(0,1.2fr)_minmax(0,1fr)_auto]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-text-faint">›</span>
|
||||||
|
<IconImg item={item} size={22} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-medium lowercase">{item.name}</span>
|
||||||
|
{host ? <span className="block truncate text-[10.5px] text-text-faint min-[760px]:hidden">{host}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="hidden min-w-0 truncate text-[11px] text-text-faint min-[760px]:block">
|
||||||
|
{host}{item.urls.length > 1 ? ` · ${item.urls.length} endpoints` : ""}
|
||||||
|
</span>
|
||||||
|
{item.urls.length > 0 ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"justify-self-end text-[10.5px] tracking-[0.04em] transition-opacity group-hover/row:opacity-0",
|
||||||
|
state === "up" ? "text-up" : state === "down" ? "text-down" : "text-text-faint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
[ {state ?? "…"} ]
|
||||||
|
</span>
|
||||||
|
) : <span />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ItemAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetView({ item, sectionId, onEdit }: ItemViewProps) {
|
||||||
|
const dnd = useItemDnd(sectionId, item.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dnd.ref}
|
||||||
|
style={dnd.style}
|
||||||
|
{...dnd.attributes}
|
||||||
|
{...dnd.listeners}
|
||||||
|
className={cn(
|
||||||
|
"group/row relative grid cursor-grab grid-cols-[16px_24px_minmax(0,1fr)] items-center gap-3",
|
||||||
|
"border-b border-border px-3 py-2 text-[12.5px] transition-colors last:border-b-0 hover:bg-surface-hover",
|
||||||
|
"min-[760px]:grid-cols-[20px_26px_minmax(0,0.8fr)_minmax(0,1.2fr)]",
|
||||||
|
dnd.isDragging && "opacity-35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-text-faint">›</span>
|
||||||
|
<IconImg item={item} size={22} />
|
||||||
|
<span className="min-w-0 truncate font-medium lowercase">{item.name}</span>
|
||||||
|
<div className="min-w-0 max-[759px]:col-start-3 max-[759px]:py-1">
|
||||||
|
<WidgetContent item={item} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Edit ${item.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-2 rounded bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/row:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminal: RendererViews = {
|
||||||
|
BoardView,
|
||||||
|
SectionView,
|
||||||
|
ItemView,
|
||||||
|
WidgetView,
|
||||||
|
itemsGridClass: "flex flex-col",
|
||||||
|
};
|
||||||
@@ -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<BoardViewProps>;
|
||||||
|
/** Section chrome: label, count, collapse, drag grip. */
|
||||||
|
SectionView: ComponentType<SectionViewProps>;
|
||||||
|
/** Service tile anatomy: icon, name, host, status, launch anchor. */
|
||||||
|
ItemView: ComponentType<ItemViewProps>;
|
||||||
|
/** Widget tile anatomy (Phase 2 fills in). */
|
||||||
|
WidgetView: ComponentType<ItemViewProps>;
|
||||||
|
/** Tailwind classes for the items grid inside a section. */
|
||||||
|
itemsGridClass: string;
|
||||||
|
}
|
||||||
@@ -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<string, Status>;
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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 <span className="font-mono text-[11px] text-text-faint">{item.name} — {error}</span>;
|
||||||
|
}
|
||||||
|
const pct = num(data, "blockedPercent");
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||||
|
{data ? `${pct.toFixed(1)}%` : "…"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
|
||||||
|
<span>{fmt(num(data, "queriesToday"))} queries</span>
|
||||||
|
<span>{fmt(num(data, "blockedToday"))} blocked</span>
|
||||||
|
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PiholeWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
|
||||||
|
return <BlockerWidget {...p} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdguardWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
|
||||||
|
return <BlockerWidget {...p} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-w-0 flex-col justify-center gap-0.5">
|
||||||
|
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
|
||||||
|
{date}{tz ? ` · ${tz.split("/").pop()?.replace("_", " ")}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <span className="font-mono text-[11px] text-text-faint">{item.name} — {error}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||||
|
{data ? fmtNum(num(data, "photos")) : "…"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
|
||||||
|
<span>{fmtNum(num(data, "videos"))} videos</span>
|
||||||
|
<span>{fmtBytes(num(data, "usageBytes"))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout>;
|
||||||
|
|
||||||
|
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 <span className="text-[11px] uppercase tracking-[0.1em] text-text-faint">{item.name || "widget"}</span>;
|
||||||
|
}
|
||||||
|
return <>{entry.view({ item, data, error })}</>;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
});
|
||||||
Whitespace-only changes.
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
dash:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infra/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -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/
|
||||||
|
<name>/
|
||||||
|
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
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dash — A · Vercel Mono</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/geist@latest/index.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
[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;
|
||||||
|
--icon-bg: #1c1c1e;
|
||||||
|
--popover: #141414;
|
||||||
|
}
|
||||||
|
[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;
|
||||||
|
--icon-bg: #f4f4f5;
|
||||||
|
--popover: #ffffff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body {
|
||||||
|
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100dvh;
|
||||||
|
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
padding: 14px 32px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
|
||||||
|
.brand svg { display: block; }
|
||||||
|
.search {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 240px; padding: 7px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-faint); font-size: 13px;
|
||||||
|
}
|
||||||
|
.search kbd {
|
||||||
|
margin-left: auto; font-family: inherit; font-size: 11px;
|
||||||
|
padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.iconbtn, .addbtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
height: 34px; border-radius: 8px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface); color: var(--text-dim);
|
||||||
|
font-family: inherit; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer; padding: 0 10px;
|
||||||
|
transition: color 150ms var(--ease), border-color 150ms var(--ease), transform 100ms var(--ease);
|
||||||
|
}
|
||||||
|
.iconbtn { width: 34px; padding: 0; }
|
||||||
|
.iconbtn:hover, .addbtn:hover { color: var(--text); border-color: var(--border-strong); }
|
||||||
|
.iconbtn:active, .addbtn:active { transform: scale(0.96); }
|
||||||
|
.addbtn.primary { background: var(--text); color: var(--bg); border-color: transparent; padding: 0 14px; }
|
||||||
|
|
||||||
|
/* ---------- layout ---------- */
|
||||||
|
main { max-width: 1120px; margin: 0 auto; padding: 40px 32px 96px; }
|
||||||
|
|
||||||
|
.pagehead { display: flex; align-items: baseline; gap: 16px; margin-bottom: 36px; }
|
||||||
|
.pagehead h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; }
|
||||||
|
.pagehead .meta { font-size: 13px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.pagehead .meta .dot { color: var(--up); }
|
||||||
|
|
||||||
|
section { margin-bottom: 40px; }
|
||||||
|
.sechead {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 4px; margin-bottom: 14px;
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.sechead h2 {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
.sechead .count { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.sechead .chev { margin-left: auto; transition: transform 200ms var(--ease); color: var(--text-faint); }
|
||||||
|
section.closed .chev { transform: rotate(-90deg); }
|
||||||
|
.secbody { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; overflow: hidden; }
|
||||||
|
section.closed .secbody { display: none; }
|
||||||
|
.sechead .grip { color: var(--text-faint); cursor: grab; opacity: 0; transition: opacity 150ms var(--ease); }
|
||||||
|
.sechead:hover .grip { opacity: 1; }
|
||||||
|
|
||||||
|
/* ---------- service card ---------- */
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 150ms var(--ease), border-color 150ms var(--ease), transform 100ms var(--ease), box-shadow 200ms var(--ease);
|
||||||
|
}
|
||||||
|
.card:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
||||||
|
.card:active { transform: scale(0.98); }
|
||||||
|
.card.dragging { opacity: 0.4; }
|
||||||
|
.card.drop-above { box-shadow: 0 -2px 0 var(--accent); }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative; flex: none;
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--icon-bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.icon::before { content: attr(data-letter); }
|
||||||
|
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; padding: 5px; background: var(--icon-bg); }
|
||||||
|
|
||||||
|
.card .txt { min-width: 0; }
|
||||||
|
.card .name { font-size: 13.5px; font-weight: 500; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.card .host { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.status { margin-left: auto; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--up); }
|
||||||
|
.status.down { background: var(--down); }
|
||||||
|
.multi { flex: none; font-size: 10px; color: var(--text-faint); border: 1px solid var(--border); border-radius: 4px; padding: 1px 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------- widgets ---------- */
|
||||||
|
.widget {
|
||||||
|
padding: 16px; border-radius: 10px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.widget .wlabel { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-faint); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.widget .wlabel img { width: 13px; height: 13px; }
|
||||||
|
.clock { font-size: 26px; font-weight: 500; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||||
|
.zones { display: flex; gap: 14px; margin-top: 6px; font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.zones b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
.stat { font-size: 22px; font-weight: 500; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||||
|
.stat-sub { font-size: 11px; color: var(--text-faint); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---------- url popover ---------- */
|
||||||
|
.urlpop {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 12px; z-index: 20;
|
||||||
|
min-width: 230px; padding: 5px;
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.35), 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
opacity: 0; transform: translateY(-4px) scale(0.98);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 150ms var(--ease), transform 150ms var(--ease);
|
||||||
|
}
|
||||||
|
.card.open .urlpop { opacity: 1; transform: none; pointer-events: auto; }
|
||||||
|
.urlpop a {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 6px;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 13px;
|
||||||
|
}
|
||||||
|
.urlpop a:hover { background: var(--surface-hover); }
|
||||||
|
.urlpop .tag {
|
||||||
|
flex: none; font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: var(--icon-bg); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.urlpop .u { color: var(--text-faint); font-size: 11.5px; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.edit-hint { color: var(--text-faint); font-size: 12px; text-align: center; margin-top: 24px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar { padding: 12px 16px; }
|
||||||
|
.search { display: none; }
|
||||||
|
main { padding: 24px 16px 80px; }
|
||||||
|
.secbody { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor"/><rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45"/><rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45"/><rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor"/></svg>
|
||||||
|
Dash
|
||||||
|
</div>
|
||||||
|
<div class="search">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
Search services
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</div>
|
||||||
|
<button class="iconbtn" id="theme" aria-label="Toggle theme">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="addbtn primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="pagehead">
|
||||||
|
<h1>Overview</h1>
|
||||||
|
<span class="meta"><span class="dot">●</span> 17 services · <span id="hdate"></span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section data-open>
|
||||||
|
<div class="sechead">
|
||||||
|
<svg class="grip" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="8" cy="6" r="1.6"/><circle cx="16" cy="6" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="18" r="1.6"/><circle cx="16" cy="18" r="1.6"/></svg>
|
||||||
|
<h2>Network</h2><span class="count">4</span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="secbody">
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.2/admin"},{"tag":"External","u":"https://pihole.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Pi-hole</div><div class="host">10.0.0.2</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.3:3000"}]'>
|
||||||
|
<div class="icon" data-letter="A"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">AdGuard Home</div><div class="host">10.0.0.3:3000</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.2:81"},{"tag":"External","u":"https://npm.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nginx-proxy-manager.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Nginx Proxy Mgr</div><div class="host">10.0.0.2:81</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.5:3001"},{"tag":"External","u":"https://status.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="U"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/uptime-kuma.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Uptime Kuma</div><div class="host">status.tdvorak.dev</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-open>
|
||||||
|
<div class="sechead">
|
||||||
|
<svg class="grip" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="8" cy="6" r="1.6"/><circle cx="16" cy="6" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="18" r="1.6"/><circle cx="16" cy="18" r="1.6"/></svg>
|
||||||
|
<h2>Media</h2><span class="count">6</span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="secbody">
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8096"},{"tag":"External","u":"https://watch.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="J"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Jellyfin</div><div class="host">watch.tdvorak.dev</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:2283"},{"tag":"External","u":"https://photos.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="I"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Immich</div><div class="host">photos.tdvorak.dev</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:4533"}]'>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Navidrome</div><div class="host">10.0.0.10:4533</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8080"}]'>
|
||||||
|
<div class="icon" data-letter="q"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/qbittorrent.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">qBittorrent</div><div class="host">10.0.0.10:8080</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8989"}]'>
|
||||||
|
<div class="icon" data-letter="S"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/sonarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Sonarr</div><div class="host">10.0.0.10:8989</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:7878"}]'>
|
||||||
|
<div class="icon" data-letter="R"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/radarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Radarr</div><div class="host">10.0.0.10:7878</div></div>
|
||||||
|
<span class="status down"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-open>
|
||||||
|
<div class="sechead">
|
||||||
|
<svg class="grip" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="8" cy="6" r="1.6"/><circle cx="16" cy="6" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="18" r="1.6"/><circle cx="16" cy="18" r="1.6"/></svg>
|
||||||
|
<h2>Infrastructure</h2><span class="count">7</span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="secbody">
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"https://10.0.0.4:8006"}]'>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/proxmox.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Proxmox</div><div class="host">10.0.0.4:8006</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.4:9000"}]'>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/portainer.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Portainer</div><div class="host">10.0.0.4:9000</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.6"},{"tag":"External","u":"https://nas.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="T"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/truenas.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">TrueNAS</div><div class="host">nas.tdvorak.dev</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.4:3100"}]'>
|
||||||
|
<div class="icon" data-letter="G"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/grafana.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Grafana</div><div class="host">10.0.0.4:3100</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"External","u":"https://vault.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="V"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/vaultwarden.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Vaultwarden</div><div class="host">vault.tdvorak.dev</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.7:8123"},{"tag":"External","u":"https://home.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="H"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/home-assistant.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Home Assistant</div><div class="host">home.tdvorak.dev</div></div>
|
||||||
|
<span class="multi">2</span><span class="status"></span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" draggable="true" data-urls='[{"tag":"External","u":"https://cloud.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nextcloud.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Nextcloud</div><div class="host">cloud.tdvorak.dev</div></div>
|
||||||
|
<span class="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-open>
|
||||||
|
<div class="sechead">
|
||||||
|
<svg class="grip" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="8" cy="6" r="1.6"/><circle cx="16" cy="6" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="18" r="1.6"/><circle cx="16" cy="18" r="1.6"/></svg>
|
||||||
|
<h2>Widgets</h2><span class="count">4</span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="secbody" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));">
|
||||||
|
<div class="widget">
|
||||||
|
<div class="wlabel">Time</div>
|
||||||
|
<div class="clock" id="clock">--:--</div>
|
||||||
|
<div class="zones"><span><b>PRG</b> <span id="tz1"></span></span><span><b>NYC</b> <span id="tz2"></span></span><span><b>TYO</b> <span id="tz3"></span></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="widget">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()">Pi-hole</div>
|
||||||
|
<div class="stat">24.6%</div>
|
||||||
|
<div class="stat-sub">ads blocked · 84,213 queries</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()">AdGuard</div>
|
||||||
|
<div class="stat">11.2%</div>
|
||||||
|
<div class="stat-sub">blocked · 31 clients</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()">Immich</div>
|
||||||
|
<div class="stat">12,483</div>
|
||||||
|
<div class="stat-sub">photos · 96.4 GB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="edit-hint">Mockup A — Vercel Mono. Drag cards to reorder. Click a card with a “2” badge to see the URL chooser.</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// theme
|
||||||
|
document.getElementById('theme').onclick = () => {
|
||||||
|
const h = document.documentElement;
|
||||||
|
h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
// clock
|
||||||
|
const fmt = (tz) => new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz });
|
||||||
|
function tick() {
|
||||||
|
document.getElementById('clock').textContent = fmt().format(new Date());
|
||||||
|
document.getElementById('tz1').textContent = fmt('Europe/Prague').format(new Date());
|
||||||
|
document.getElementById('tz2').textContent = fmt('America/New_York').format(new Date());
|
||||||
|
document.getElementById('tz3').textContent = fmt('Asia/Tokyo').format(new Date());
|
||||||
|
document.getElementById('hdate').textContent = new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 10000);
|
||||||
|
|
||||||
|
// collapse sections
|
||||||
|
document.querySelectorAll('.sechead').forEach(h =>
|
||||||
|
h.addEventListener('click', () => h.parentElement.classList.toggle('closed')));
|
||||||
|
|
||||||
|
// multi-URL popover
|
||||||
|
document.querySelectorAll('.card[data-urls]').forEach(card => {
|
||||||
|
const urls = JSON.parse(card.dataset.urls);
|
||||||
|
if (urls.length < 2) { card.addEventListener('click', () => {}); return; }
|
||||||
|
const pop = card.querySelector('.urlpop');
|
||||||
|
pop.innerHTML = urls.map(x => `<a href="#" onclick="return false"><span class="tag">${x.tag}</span>${new URL(x.u).hostname}<span class="u">↗</span></a>`).join('');
|
||||||
|
card.addEventListener('click', e => {
|
||||||
|
if (card.classList.contains('dragging')) return;
|
||||||
|
document.querySelectorAll('.card.open').forEach(c => c !== card && c.classList.remove('open'));
|
||||||
|
card.classList.toggle('open');
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => document.querySelectorAll('.card.open').forEach(c => c.classList.remove('open')));
|
||||||
|
|
||||||
|
// drag reorder (native, mockup-grade — real build uses dnd-kit)
|
||||||
|
let dragEl = null;
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', () => { dragEl = card; setTimeout(() => card.classList.add('dragging')); });
|
||||||
|
card.addEventListener('dragend', () => { card.classList.remove('dragging'); document.querySelectorAll('.drop-above').forEach(c => c.classList.remove('drop-above')); });
|
||||||
|
card.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (card === dragEl) return;
|
||||||
|
const body = card.parentElement;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
const before = e.clientX < r.left + r.width / 2;
|
||||||
|
body.insertBefore(dragEl, before ? card : card.nextSibling);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.secbody').forEach(b => b.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragEl && !b.contains(dragEl) && e.target === b) b.appendChild(dragEl);
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dash — D · Bento Mono</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/geist@latest/index.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/geist-mono@latest/index.css">
|
||||||
|
<style>
|
||||||
|
:root { --ease: cubic-bezier(0.2, 0, 0, 1); }
|
||||||
|
[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;
|
||||||
|
--icon-bg: #1c1c1e;
|
||||||
|
--popover: #141414;
|
||||||
|
}
|
||||||
|
[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;
|
||||||
|
--icon-bg: #f4f4f5;
|
||||||
|
--popover: #ffffff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body {
|
||||||
|
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); min-height: 100dvh;
|
||||||
|
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||||
|
}
|
||||||
|
.mono { font-family: "Geist Mono", ui-monospace, monospace; }
|
||||||
|
|
||||||
|
/* ---------- top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 14px 30px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
|
||||||
|
.search {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 230px; padding: 7px 12px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text-faint); font-size: 13px;
|
||||||
|
}
|
||||||
|
.search kbd { margin-left: auto; font-family: inherit; font-size: 11px; padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; }
|
||||||
|
.iconbtn, .addbtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
height: 34px; border-radius: 8px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface); color: var(--text-dim);
|
||||||
|
font-family: inherit; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer; padding: 0 10px;
|
||||||
|
transition: color 150ms var(--ease), border-color 150ms var(--ease), transform 100ms var(--ease);
|
||||||
|
}
|
||||||
|
.iconbtn { width: 34px; padding: 0; }
|
||||||
|
.iconbtn:hover, .addbtn:hover { color: var(--text); border-color: var(--border-strong); }
|
||||||
|
.iconbtn:active, .addbtn:active { transform: scale(0.96); }
|
||||||
|
.addbtn.primary { background: var(--text); color: var(--bg); border: none; padding: 0 14px; }
|
||||||
|
|
||||||
|
/* ---------- bento ---------- */
|
||||||
|
main { max-width: 1140px; margin: 0 auto; padding: 36px 30px 90px; }
|
||||||
|
.bento { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
|
||||||
|
|
||||||
|
.sec {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 4px 2px; margin-top: 20px;
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.sec:first-child { margin-top: 0; }
|
||||||
|
.sec h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim); }
|
||||||
|
.sec .count { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--text-faint); }
|
||||||
|
.sec .line { flex: 1; height: 1px; background: var(--border); }
|
||||||
|
.sec .chev { color: var(--text-faint); transition: transform 200ms var(--ease); }
|
||||||
|
.sec.closed .chev { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
/* ---------- service tile ---------- */
|
||||||
|
.tile {
|
||||||
|
grid-column: span 2;
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 13px 15px; min-height: 70px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; cursor: grab;
|
||||||
|
transition: background 150ms var(--ease), border-color 150ms var(--ease), transform 100ms var(--ease);
|
||||||
|
}
|
||||||
|
.tile:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
||||||
|
.tile:active { transform: scale(0.98); }
|
||||||
|
.tile.dragging { opacity: 0.35; }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative; flex: none;
|
||||||
|
width: 40px; height: 40px; border-radius: 9px;
|
||||||
|
background: var(--icon-bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 15px; font-weight: 600; color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.icon::before { content: attr(data-letter); }
|
||||||
|
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; padding: 6px; background: var(--icon-bg); }
|
||||||
|
|
||||||
|
.tile .txt { min-width: 0; }
|
||||||
|
.tile .name { font-size: 13.5px; font-weight: 500; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tile .host { font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.chip {
|
||||||
|
margin-left: auto; flex: none;
|
||||||
|
font-family: "Geist Mono", monospace; font-size: 9.5px;
|
||||||
|
padding: 3px 7px; border-radius: 5px; letter-spacing: 0.06em;
|
||||||
|
border: 1px solid var(--border); color: var(--text-faint);
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.chip::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--up); }
|
||||||
|
.chip.down::before { background: var(--down); }
|
||||||
|
|
||||||
|
/* ---------- widget tiles ---------- */
|
||||||
|
.widget {
|
||||||
|
grid-column: span 3; grid-row: span 2;
|
||||||
|
padding: 17px; border-radius: 10px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; min-height: 150px;
|
||||||
|
}
|
||||||
|
.widget.s2 { grid-column: span 2; }
|
||||||
|
.wlabel { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-faint); display: flex; align-items: center; gap: 7px; }
|
||||||
|
.wlabel img { width: 13px; height: 13px; }
|
||||||
|
.wlabel .live { margin-left: auto; color: var(--up); font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.1em; }
|
||||||
|
.clock { font-family: "Geist Mono", monospace; font-size: 32px; font-weight: 500; letter-spacing: -0.03em; margin-top: auto; font-variant-numeric: tabular-nums; }
|
||||||
|
.zones { display: flex; gap: 15px; margin-top: 8px; font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.zones b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
.bigstat { font-family: "Geist Mono", monospace; font-size: 28px; font-weight: 500; letter-spacing: -0.03em; margin-top: auto; font-variant-numeric: tabular-nums; }
|
||||||
|
.statsub { font-size: 11px; color: var(--text-faint); margin-top: 6px; }
|
||||||
|
.bars { display: flex; align-items: flex-end; gap: 3px; height: 32px; margin-top: 12px; }
|
||||||
|
.bars i { flex: 1; border-radius: 2px; background: var(--border-strong); }
|
||||||
|
.bars i.hi { background: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ---------- url popover ---------- */
|
||||||
|
.urlpop {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 13px; z-index: 20;
|
||||||
|
min-width: 235px; padding: 5px;
|
||||||
|
background: var(--popover); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.35), 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
opacity: 0; transform: translateY(-4px) scale(0.98); pointer-events: none;
|
||||||
|
transition: opacity 150ms var(--ease), transform 150ms var(--ease);
|
||||||
|
}
|
||||||
|
.tile.open .urlpop { opacity: 1; transform: none; pointer-events: auto; }
|
||||||
|
.urlpop a {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 6px;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 13px;
|
||||||
|
}
|
||||||
|
.urlpop a:hover { background: var(--surface-hover); }
|
||||||
|
.urlpop .tag {
|
||||||
|
flex: none; font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: var(--icon-bg); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.urlpop .u { color: var(--text-faint); font-size: 11.5px; margin-left: auto; font-family: "Geist Mono", monospace; }
|
||||||
|
|
||||||
|
.edit-hint { grid-column: 1 / -1; color: var(--text-faint); font-size: 12px; text-align: center; margin-top: 30px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) { .bento { grid-template-columns: repeat(4, 1fr); } .widget { grid-column: span 4; } .widget.s2 { grid-column: span 2; } }
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.topbar { padding: 10px 16px; } .search { display: none; }
|
||||||
|
main { padding: 22px 14px 70px; }
|
||||||
|
.bento { grid-template-columns: 1fr 1fr; }
|
||||||
|
.tile { grid-column: span 1; } .widget, .widget.s2 { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor"/><rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45"/><rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45"/><rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor"/></svg>
|
||||||
|
Dash
|
||||||
|
</div>
|
||||||
|
<div class="search">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
Search services
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</div>
|
||||||
|
<button class="iconbtn" id="theme" aria-label="Toggle theme">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="addbtn primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="bento">
|
||||||
|
|
||||||
|
<div class="sec"><h2>Status</h2><span class="count">05</span><span class="line"></span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<div class="wlabel">Local time <span class="live">● LIVE</span></div>
|
||||||
|
<div class="clock" id="clock">--:--:--</div>
|
||||||
|
<div class="zones"><span><b>PRG</b> <span id="tz1"></span></span><span><b>NYC</b> <span id="tz2"></span></span><span><b>TYO</b> <span id="tz3"></span></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="widget">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()">Pi-hole · today</div>
|
||||||
|
<div class="bigstat">24.6%</div>
|
||||||
|
<div class="statsub">20,721 ads blocked · 84,213 queries</div>
|
||||||
|
<div class="bars"><i style="height:30%"></i><i class="hi" style="height:45%"></i><i style="height:35%"></i><i class="hi" style="height:60%"></i><i class="hi" style="height:52%"></i><i style="height:40%"></i><i class="hi" style="height:75%"></i><i class="hi" style="height:64%"></i><i style="height:48%"></i><i class="hi" style="height:82%"></i><i class="hi" style="height:70%"></i><i class="hi" style="height:90%"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="widget s2">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()">AdGuard</div>
|
||||||
|
<div class="bigstat">11.2%</div>
|
||||||
|
<div class="statsub">blocked · 31 clients</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget s2">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()">Immich</div>
|
||||||
|
<div class="bigstat">12,483</div>
|
||||||
|
<div class="statsub">photos · 96.4 GB</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget s2">
|
||||||
|
<div class="wlabel">Uptime · 30d</div>
|
||||||
|
<div class="bigstat">99.7%</div>
|
||||||
|
<div class="statsub">1 incident · Radarr</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget s2">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/truenas.png" alt="" onerror="this.remove()">Storage</div>
|
||||||
|
<div class="bigstat">61%</div>
|
||||||
|
<div class="statsub">14.2 / 23.2 TB</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget s2">
|
||||||
|
<div class="wlabel"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/qbittorrent.png" alt="" onerror="this.remove()">Transfer</div>
|
||||||
|
<div class="bigstat">42 MB/s</div>
|
||||||
|
<div class="statsub">↓ 38.4 · ↑ 3.6 · 7 active</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec"><h2>Network</h2><span class="count">04</span><span class="line"></span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.2/admin"},{"tag":"External","u":"https://pihole.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Pi-hole</div><div class="host">10.0.0.2</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.3:3000"}]'>
|
||||||
|
<div class="icon" data-letter="A"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">AdGuard Home</div><div class="host">10.0.0.3:3000</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.2:81"},{"tag":"External","u":"https://npm.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nginx-proxy-manager.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Nginx Proxy Mgr</div><div class="host">10.0.0.2:81</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.5:3001"},{"tag":"External","u":"https://status.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="U"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/uptime-kuma.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Uptime Kuma</div><div class="host">status.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec"><h2>Media</h2><span class="count">06</span><span class="line"></span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8096"},{"tag":"External","u":"https://watch.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="J"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Jellyfin</div><div class="host">watch.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:2283"},{"tag":"External","u":"https://photos.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="I"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Immich</div><div class="host">photos.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:4533"}]'>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Navidrome</div><div class="host">10.0.0.10:4533</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8080"}]'>
|
||||||
|
<div class="icon" data-letter="q"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/qbittorrent.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">qBittorrent</div><div class="host">10.0.0.10:8080</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8989"}]'>
|
||||||
|
<div class="icon" data-letter="S"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/sonarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Sonarr</div><div class="host">10.0.0.10:8989</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:7878"}]'>
|
||||||
|
<div class="icon" data-letter="R"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/radarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Radarr</div><div class="host">10.0.0.10:7878</div></div>
|
||||||
|
<span class="chip down">down</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec"><h2>Infrastructure</h2><span class="count">07</span><span class="line"></span>
|
||||||
|
<svg class="chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"https://10.0.0.4:8006"}]'>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/proxmox.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Proxmox</div><div class="host">10.0.0.4:8006</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.4:9000"}]'>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/portainer.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Portainer</div><div class="host">10.0.0.4:9000</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.6"},{"tag":"External","u":"https://nas.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="T"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/truenas.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">TrueNAS</div><div class="host">nas.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.4:3100"}]'>
|
||||||
|
<div class="icon" data-letter="G"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/grafana.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Grafana</div><div class="host">10.0.0.4:3100</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"External","u":"https://vault.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="V"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/vaultwarden.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Vaultwarden</div><div class="host">vault.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.7:8123"},{"tag":"External","u":"https://home.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="H"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/home-assistant.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Home Assistant</div><div class="host">home.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile" draggable="true" data-urls='[{"tag":"External","u":"https://cloud.tdvorak.dev"}]'>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nextcloud.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<div class="txt"><div class="name">Nextcloud</div><div class="host">cloud.tdvorak.dev</div></div>
|
||||||
|
<span class="chip">up</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="edit-hint">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.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('theme').onclick = () => {
|
||||||
|
const h = document.documentElement;
|
||||||
|
h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (tz, s) => new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: s ? '2-digit' : undefined, timeZone: tz });
|
||||||
|
function tick() {
|
||||||
|
const n = new Date();
|
||||||
|
document.getElementById('clock').textContent = fmt(undefined, true).format(n);
|
||||||
|
document.getElementById('tz1').textContent = fmt('Europe/Prague').format(n);
|
||||||
|
document.getElementById('tz2').textContent = fmt('America/New_York').format(n);
|
||||||
|
document.getElementById('tz3').textContent = fmt('Asia/Tokyo').format(n);
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 1000);
|
||||||
|
|
||||||
|
document.querySelectorAll('.sec').forEach(h => h.addEventListener('click', () => {
|
||||||
|
h.classList.toggle('closed');
|
||||||
|
const hide = h.classList.contains('closed');
|
||||||
|
let el = h.nextElementSibling;
|
||||||
|
while (el && !el.classList.contains('sec') && !el.classList.contains('edit-hint')) { el.style.display = hide ? 'none' : ''; el = el.nextElementSibling; }
|
||||||
|
}));
|
||||||
|
|
||||||
|
document.querySelectorAll('.tile[data-urls]').forEach(card => {
|
||||||
|
const urls = JSON.parse(card.dataset.urls);
|
||||||
|
if (urls.length < 2) return;
|
||||||
|
const pop = card.querySelector('.urlpop');
|
||||||
|
pop.innerHTML = urls.map(x => `<a href="#" onclick="return false"><span class="tag">${x.tag}</span>${new URL(x.u).hostname}<span class="u">↗</span></a>`).join('');
|
||||||
|
card.addEventListener('click', e => {
|
||||||
|
if (card.classList.contains('dragging')) return;
|
||||||
|
document.querySelectorAll('.tile.open').forEach(c => c !== card && c.classList.remove('open'));
|
||||||
|
card.classList.toggle('open');
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => document.querySelectorAll('.tile.open').forEach(c => c.classList.remove('open')));
|
||||||
|
|
||||||
|
let dragEl = null;
|
||||||
|
document.querySelectorAll('.tile').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', () => { dragEl = card; setTimeout(() => card.classList.add('dragging')); });
|
||||||
|
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||||||
|
card.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (card === dragEl) return;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
const before = e.clientX < r.left + r.width / 2;
|
||||||
|
card.parentElement.insertBefore(dragEl, before ? card : card.nextSibling);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dash — E · Editorial</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/geist@latest/index.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/geist-mono@latest/index.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/instrument-serif@latest/index.css">
|
||||||
|
<style>
|
||||||
|
:root { --ease: cubic-bezier(0.2, 0, 0, 1); }
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0b0b0c;
|
||||||
|
--surface: #131315;
|
||||||
|
--surface-hover: #17171a;
|
||||||
|
--border: rgba(255,255,255,0.09);
|
||||||
|
--border-strong: rgba(255,255,255,0.18);
|
||||||
|
--text: #f4f4f5;
|
||||||
|
--text-dim: #a1a1aa;
|
||||||
|
--text-faint: #565660;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--up: #22c55e;
|
||||||
|
--down: #f59e0b;
|
||||||
|
--icon-bg: #1b1b1e;
|
||||||
|
--popover: #151517;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #fafaf8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: #f5f4f1;
|
||||||
|
--border: rgba(0,0,0,0.10);
|
||||||
|
--border-strong: rgba(0,0,0,0.2);
|
||||||
|
--text: #17171a;
|
||||||
|
--text-dim: #565660;
|
||||||
|
--text-faint: #9c9c94;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--up: #16a34a;
|
||||||
|
--down: #b45309;
|
||||||
|
--icon-bg: #f0efec;
|
||||||
|
--popover: #ffffff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body {
|
||||||
|
font-family: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); min-height: 100dvh;
|
||||||
|
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||||
|
}
|
||||||
|
.serif { font-family: "Instrument Serif", Georgia, serif; font-weight: 400; }
|
||||||
|
.mono { font-family: "Geist Mono", ui-monospace, monospace; }
|
||||||
|
|
||||||
|
/* ---------- top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
padding: 16px 36px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.brand { font-size: 20px; letter-spacing: -0.01em; }
|
||||||
|
.brand em { font-style: normal; color: var(--text-faint); }
|
||||||
|
.search {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 230px; padding: 7px 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text-faint); font-size: 13px;
|
||||||
|
}
|
||||||
|
.search kbd { margin-left: auto; font-size: 11px; padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; }
|
||||||
|
.iconbtn, .addbtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
height: 34px; border-radius: 6px; border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--text-dim);
|
||||||
|
font-family: inherit; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer; padding: 0 10px;
|
||||||
|
transition: color 150ms var(--ease), border-color 150ms var(--ease), transform 100ms var(--ease);
|
||||||
|
}
|
||||||
|
.iconbtn { width: 34px; padding: 0; }
|
||||||
|
.iconbtn:hover, .addbtn:hover { color: var(--text); border-color: var(--border-strong); }
|
||||||
|
.iconbtn:active, .addbtn:active { transform: scale(0.96); }
|
||||||
|
.addbtn.primary { background: var(--text); color: var(--bg); border: none; padding: 0 14px; }
|
||||||
|
|
||||||
|
/* ---------- page ---------- */
|
||||||
|
main { max-width: 980px; margin: 0 auto; padding: 48px 36px 100px; }
|
||||||
|
|
||||||
|
.masthead { display: flex; align-items: baseline; gap: 18px; padding-bottom: 28px; border-bottom: 1px solid var(--border-strong); }
|
||||||
|
.masthead h1 { font-family: "Instrument Serif", Georgia, serif; font-weight: 400; font-size: 52px; letter-spacing: -0.02em; line-height: 1; }
|
||||||
|
.masthead .date { margin-left: auto; font-family: "Geist Mono", monospace; font-size: 11.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* stat strip — newspaper info bar */
|
||||||
|
.strip {
|
||||||
|
display: grid; grid-template-columns: repeat(5, 1fr);
|
||||||
|
border-bottom: 1px solid var(--border-strong);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.cell { padding: 18px 20px 16px; border-right: 1px solid var(--border); }
|
||||||
|
.cell:last-child { border-right: none; }
|
||||||
|
.cell .k { font-family: "Geist Mono", monospace; font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-faint); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.cell .k img { width: 12px; height: 12px; }
|
||||||
|
.cell .v { font-family: "Geist Mono", monospace; font-size: 20px; font-weight: 500; margin-top: 8px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||||
|
.cell .s { font-size: 11px; color: var(--text-faint); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ---------- sections ---------- */
|
||||||
|
section { margin-bottom: 34px; }
|
||||||
|
.sechead {
|
||||||
|
display: flex; align-items: baseline; gap: 14px;
|
||||||
|
padding-bottom: 10px; margin-bottom: 2px;
|
||||||
|
border-bottom: 1px solid var(--border-strong);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.sechead .idx { font-family: "Instrument Serif", Georgia, serif; font-size: 30px; color: var(--text-faint); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
|
.sechead h2 { font-family: "Instrument Serif", Georgia, serif; font-weight: 400; font-size: 30px; letter-spacing: -0.01em; }
|
||||||
|
.sechead .count { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--text-faint); }
|
||||||
|
.sechead .chev { margin-left: auto; color: var(--text-faint); transition: transform 200ms var(--ease); }
|
||||||
|
section.closed .chev { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
/* ---------- service rows ---------- */
|
||||||
|
.rows { }
|
||||||
|
section.closed .rows { display: none; }
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30px 34px minmax(0,1.4fr) minmax(0,1fr) auto 70px 22px;
|
||||||
|
align-items: center; gap: 14px;
|
||||||
|
padding: 11px 6px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 150ms var(--ease);
|
||||||
|
}
|
||||||
|
.row:hover { background: var(--surface-hover); }
|
||||||
|
.row.dragging { opacity: 0.35; }
|
||||||
|
.row .n { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.icon {
|
||||||
|
position: relative; width: 30px; height: 30px; border-radius: 7px;
|
||||||
|
background: var(--icon-bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.icon::before { content: attr(data-letter); }
|
||||||
|
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; padding: 4px; background: var(--icon-bg); }
|
||||||
|
.row .name { font-size: 14px; font-weight: 500; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row .host { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row .tags { display: flex; gap: 5px; justify-content: flex-end; }
|
||||||
|
.row .tag {
|
||||||
|
font-family: "Geist Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--text-faint); border: 1px solid var(--border); border-radius: 4px; padding: 2px 5px;
|
||||||
|
}
|
||||||
|
.row .stat { font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.05em; display: flex; align-items: center; gap: 6px; color: var(--text-dim); }
|
||||||
|
.row .stat::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--up); }
|
||||||
|
.row .stat.down::before { background: var(--down); }
|
||||||
|
.row .go { color: var(--text-faint); opacity: 0; transform: translateX(-4px); transition: opacity 150ms var(--ease), transform 150ms var(--ease); }
|
||||||
|
.row:hover .go { opacity: 1; transform: none; }
|
||||||
|
|
||||||
|
/* ---------- url popover ---------- */
|
||||||
|
.urlpop {
|
||||||
|
position: absolute; top: calc(100% + 2px); left: 44px; z-index: 20;
|
||||||
|
min-width: 250px; padding: 5px;
|
||||||
|
background: var(--popover); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.35);
|
||||||
|
opacity: 0; transform: translateY(-3px); pointer-events: none;
|
||||||
|
transition: opacity 150ms var(--ease), transform 150ms var(--ease);
|
||||||
|
}
|
||||||
|
.row.open .urlpop { opacity: 1; transform: none; pointer-events: auto; }
|
||||||
|
.urlpop a {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 5px;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 13px;
|
||||||
|
}
|
||||||
|
.urlpop a:hover { background: var(--surface-hover); }
|
||||||
|
.urlpop .tag {
|
||||||
|
flex: none; font-family: "Geist Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding: 2px 6px; border-radius: 4px; border: 1px solid var(--border); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.urlpop .u { color: var(--text-faint); font-size: 11px; margin-left: auto; font-family: "Geist Mono", monospace; }
|
||||||
|
|
||||||
|
.foot { margin-top: 46px; padding-top: 18px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--text-faint); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar { padding: 12px 16px; } .search { display: none; }
|
||||||
|
main { padding: 28px 16px 70px; }
|
||||||
|
.masthead h1 { font-size: 38px; }
|
||||||
|
.strip { grid-template-columns: 1fr 1fr; } .cell { border-bottom: 1px solid var(--border); }
|
||||||
|
.row { grid-template-columns: 26px 32px minmax(0,1fr) 60px; }
|
||||||
|
.row .host, .row .tags, .row .n { display: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand serif">Dash<em>.</em></div>
|
||||||
|
<div class="search">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
Search the index
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</div>
|
||||||
|
<button class="iconbtn" id="theme" aria-label="Toggle theme">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="addbtn primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="masthead">
|
||||||
|
<h1>Service Index</h1>
|
||||||
|
<span class="date" id="hdate"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="strip">
|
||||||
|
<div class="cell"><div class="k">Local time</div><div class="v" id="clock">--:--</div><div class="s"><span id="tz1"></span> PRG · <span id="tz2"></span> NYC · <span id="tz3"></span> TYO</div></div>
|
||||||
|
<div class="cell"><div class="k"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()">Pi-hole</div><div class="v">24.6%</div><div class="s">ads blocked today</div></div>
|
||||||
|
<div class="cell"><div class="k"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()">AdGuard</div><div class="v">11.2%</div><div class="s">31 clients</div></div>
|
||||||
|
<div class="cell"><div class="k"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()">Immich</div><div class="v">12,483</div><div class="s">photos · 96.4 GB</div></div>
|
||||||
|
<div class="cell"><div class="k">Uptime · 30d</div><div class="v">99.7%</div><div class="s">1 incident</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sechead"><span class="idx">01</span><h2>Network</h2><span class="count">4 entries</span>
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.2/admin"},{"tag":"External","u":"https://pihole.tdvorak.dev"}]'>
|
||||||
|
<span class="n">01</span>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Pi-hole</span><span class="host">10.0.0.2</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.3:3000"}]'>
|
||||||
|
<span class="n">02</span>
|
||||||
|
<div class="icon" data-letter="A"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">AdGuard Home</span><span class="host">10.0.0.3:3000</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.2:81"},{"tag":"External","u":"https://npm.tdvorak.dev"}]'>
|
||||||
|
<span class="n">03</span>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nginx-proxy-manager.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Nginx Proxy Mgr</span><span class="host">10.0.0.2:81</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.5:3001"},{"tag":"External","u":"https://status.tdvorak.dev"}]'>
|
||||||
|
<span class="n">04</span>
|
||||||
|
<div class="icon" data-letter="U"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/uptime-kuma.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Uptime Kuma</span><span class="host">status.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sechead"><span class="idx">02</span><h2>Media</h2><span class="count">6 entries</span>
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8096"},{"tag":"External","u":"https://watch.tdvorak.dev"}]'>
|
||||||
|
<span class="n">01</span>
|
||||||
|
<div class="icon" data-letter="J"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Jellyfin</span><span class="host">watch.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:2283"},{"tag":"External","u":"https://photos.tdvorak.dev"}]'>
|
||||||
|
<span class="n">02</span>
|
||||||
|
<div class="icon" data-letter="I"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Immich</span><span class="host">photos.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:4533"}]'>
|
||||||
|
<span class="n">03</span>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Navidrome</span><span class="host">10.0.0.10:4533</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8080"}]'>
|
||||||
|
<span class="n">04</span>
|
||||||
|
<div class="icon" data-letter="q"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/qbittorrent.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">qBittorrent</span><span class="host">10.0.0.10:8080</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:8989"}]'>
|
||||||
|
<span class="n">05</span>
|
||||||
|
<div class="icon" data-letter="S"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/sonarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Sonarr</span><span class="host">10.0.0.10:8989</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.10:7878"}]'>
|
||||||
|
<span class="n">06</span>
|
||||||
|
<div class="icon" data-letter="R"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/radarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Radarr</span><span class="host">10.0.0.10:7878</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat down">down</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sechead"><span class="idx">03</span><h2>Infrastructure</h2><span class="count">7 entries</span>
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"https://10.0.0.4:8006"}]'>
|
||||||
|
<span class="n">01</span>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/proxmox.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Proxmox</span><span class="host">10.0.0.4:8006</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.4:9000"}]'>
|
||||||
|
<span class="n">02</span>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/portainer.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Portainer</span><span class="host">10.0.0.4:9000</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.6"},{"tag":"External","u":"https://nas.tdvorak.dev"}]'>
|
||||||
|
<span class="n">03</span>
|
||||||
|
<div class="icon" data-letter="T"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/truenas.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">TrueNAS</span><span class="host">nas.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.4:3100"}]'>
|
||||||
|
<span class="n">04</span>
|
||||||
|
<div class="icon" data-letter="G"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/grafana.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Grafana</span><span class="host">10.0.0.4:3100</span>
|
||||||
|
<span class="tags"><span class="tag">local</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"External","u":"https://vault.tdvorak.dev"}]'>
|
||||||
|
<span class="n">05</span>
|
||||||
|
<div class="icon" data-letter="V"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/vaultwarden.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Vaultwarden</span><span class="host">vault.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"Local","u":"http://10.0.0.7:8123"},{"tag":"External","u":"https://home.tdvorak.dev"}]'>
|
||||||
|
<span class="n">06</span>
|
||||||
|
<div class="icon" data-letter="H"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/home-assistant.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Home Assistant</span><span class="host">home.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">local</span><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"External","u":"https://cloud.tdvorak.dev"}]'>
|
||||||
|
<span class="n">07</span>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nextcloud.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">Nextcloud</span><span class="host">cloud.tdvorak.dev</span>
|
||||||
|
<span class="tags"><span class="tag">ext</span></span>
|
||||||
|
<span class="stat">up</span><span class="go">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="foot"><span>dash v0.1 · 17 services · 4 widgets</span><span>mockup E — editorial</span></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('theme').onclick = () => {
|
||||||
|
const h = document.documentElement;
|
||||||
|
h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (tz) => new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz });
|
||||||
|
function tick() {
|
||||||
|
const n = new Date();
|
||||||
|
document.getElementById('clock').textContent = fmt().format(n);
|
||||||
|
document.getElementById('tz1').textContent = fmt('Europe/Prague').format(n);
|
||||||
|
document.getElementById('tz2').textContent = fmt('America/New_York').format(n);
|
||||||
|
document.getElementById('tz3').textContent = fmt('Asia/Tokyo').format(n);
|
||||||
|
document.getElementById('hdate').textContent = n.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 10000);
|
||||||
|
|
||||||
|
document.querySelectorAll('.sechead').forEach(h =>
|
||||||
|
h.addEventListener('click', () => h.parentElement.classList.toggle('closed')));
|
||||||
|
|
||||||
|
// renumber rows after drags
|
||||||
|
function renumber(rows) { rows.querySelectorAll('.row .n').forEach((n, i) => n.textContent = String(i + 1).padStart(2, '0')); }
|
||||||
|
|
||||||
|
document.querySelectorAll('.row[data-urls]').forEach(card => {
|
||||||
|
const urls = JSON.parse(card.dataset.urls);
|
||||||
|
if (urls.length < 2) return;
|
||||||
|
const pop = card.querySelector('.urlpop');
|
||||||
|
pop.innerHTML = urls.map(x => `<a href="#" onclick="return false"><span class="tag">${x.tag}</span>${new URL(x.u).hostname}<span class="u">↗</span></a>`).join('');
|
||||||
|
card.addEventListener('click', e => {
|
||||||
|
if (card.classList.contains('dragging')) return;
|
||||||
|
document.querySelectorAll('.row.open').forEach(c => c !== card && c.classList.remove('open'));
|
||||||
|
card.classList.toggle('open');
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => document.querySelectorAll('.row.open').forEach(c => c.classList.remove('open')));
|
||||||
|
|
||||||
|
let dragEl = null;
|
||||||
|
document.querySelectorAll('.row').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', () => { dragEl = card; setTimeout(() => card.classList.add('dragging')); });
|
||||||
|
card.addEventListener('dragend', () => { card.classList.remove('dragging'); renumber(card.parentElement); });
|
||||||
|
card.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (card === dragEl) return;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
const before = e.clientY < r.top + r.height / 2;
|
||||||
|
card.parentElement.insertBefore(dragEl, before ? card : card.nextSibling);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.rows').forEach(b => b.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragEl && e.target === b) { b.appendChild(dragEl); renumber(b); }
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dash — F · Console</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/jetbrains-mono@latest/index.css">
|
||||||
|
<style>
|
||||||
|
:root { --ease: cubic-bezier(0.2, 0, 0, 1); }
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0c0c0d;
|
||||||
|
--surface: #111113;
|
||||||
|
--surface-hover: #17171a;
|
||||||
|
--border: rgba(255,255,255,0.10);
|
||||||
|
--border-strong: rgba(255,255,255,0.2);
|
||||||
|
--text: #e8e8ea;
|
||||||
|
--text-dim: #9c9ca4;
|
||||||
|
--text-faint: #55555e;
|
||||||
|
--up: #4ade80;
|
||||||
|
--down: #fbbf24;
|
||||||
|
--sel: rgba(255,255,255,0.06);
|
||||||
|
--icon-bg: #1a1a1d;
|
||||||
|
--popover: #141416;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f5f4ef;
|
||||||
|
--surface: #fdfdfb;
|
||||||
|
--surface-hover: #efeee9;
|
||||||
|
--border: rgba(0,0,0,0.14);
|
||||||
|
--border-strong: rgba(0,0,0,0.28);
|
||||||
|
--text: #1c1c1f;
|
||||||
|
--text-dim: #55555e;
|
||||||
|
--text-faint: #9d9d95;
|
||||||
|
--up: #15803d;
|
||||||
|
--down: #b45309;
|
||||||
|
--sel: rgba(0,0,0,0.05);
|
||||||
|
--icon-bg: #ecebe6;
|
||||||
|
--popover: #fdfdfb;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body {
|
||||||
|
font-family: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
|
||||||
|
background: var(--bg); color: var(--text); min-height: 100dvh;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 0 24px; height: 46px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||||
|
.brand .cursor { display: inline-block; width: 8px; height: 15px; background: var(--text); animation: blink 1.1s steps(1) infinite; }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
.path { color: var(--text-faint); }
|
||||||
|
.path b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
.search {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 230px; padding: 5px 10px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
color: var(--text-faint); font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.tbtn {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
height: 28px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text-dim);
|
||||||
|
font-family: inherit; font-size: 11.5px; cursor: pointer;
|
||||||
|
transition: color 120ms var(--ease), border-color 120ms var(--ease), background 120ms var(--ease);
|
||||||
|
}
|
||||||
|
.tbtn:hover { color: var(--text); border-color: var(--border-strong); background: var(--sel); }
|
||||||
|
.tbtn.primary { background: var(--text); color: var(--bg); border-color: var(--text); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ---------- layout ---------- */
|
||||||
|
main { max-width: 1060px; margin: 0 auto; padding: 30px 24px 90px; }
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
font-size: 11px; color: var(--text-faint); line-height: 1.7;
|
||||||
|
padding: 14px 16px; margin-bottom: 26px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.banner b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
.banner .ok { color: var(--up); }
|
||||||
|
|
||||||
|
/* ---------- widget panels ---------- */
|
||||||
|
.panels { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 26px; }
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
background: var(--surface); overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel .phead {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.panel .phead img { width: 12px; height: 12px; }
|
||||||
|
.panel .phead .r { margin-left: auto; color: var(--up); font-size: 9px; }
|
||||||
|
.panel .pbody { padding: 12px; }
|
||||||
|
.panel .v { font-size: 22px; font-weight: 500; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||||
|
.panel .s { font-size: 10.5px; color: var(--text-faint); margin-top: 4px; }
|
||||||
|
.panel.wide { grid-column: span 2; }
|
||||||
|
.panel .clockv { font-size: 26px; font-variant-numeric: tabular-nums; }
|
||||||
|
.tzrow { display: flex; gap: 14px; margin-top: 8px; font-size: 10.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.tzrow b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
|
||||||
|
/* ---------- sections ---------- */
|
||||||
|
.sec { margin-bottom: 24px; }
|
||||||
|
.sechead {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 4px 2px; margin-bottom: 8px;
|
||||||
|
font-size: 12px; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.sechead .hash { color: var(--text-faint); }
|
||||||
|
.sechead h2 { font-size: 12px; font-weight: 700; text-transform: lowercase; letter-spacing: 0.02em; }
|
||||||
|
.sechead .n { color: var(--text-faint); font-size: 11px; }
|
||||||
|
.sechead .fill { flex: 1; color: var(--text-faint); overflow: hidden; white-space: nowrap; }
|
||||||
|
.sechead .chev { color: var(--text-faint); transition: transform 150ms var(--ease); }
|
||||||
|
.sec.closed .chev { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
.table {
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
background: var(--surface); overflow: hidden;
|
||||||
|
}
|
||||||
|
.sec.closed .table { display: none; }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 30px minmax(0,1.2fr) minmax(0,1fr) auto;
|
||||||
|
align-items: center; gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: grab; font-size: 12.5px;
|
||||||
|
transition: background 120ms var(--ease);
|
||||||
|
}
|
||||||
|
.row:last-child { border-bottom: none; }
|
||||||
|
.row:hover { background: var(--sel); }
|
||||||
|
.row.dragging { opacity: 0.35; }
|
||||||
|
.row .prompt { color: var(--text-faint); }
|
||||||
|
.icon {
|
||||||
|
position: relative; width: 22px; height: 22px; border-radius: 5px;
|
||||||
|
background: var(--icon-bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; font-weight: 700; color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.icon::before { content: attr(data-letter); }
|
||||||
|
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; padding: 3px; background: var(--icon-bg); }
|
||||||
|
.row .name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row .host { color: var(--text-faint); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row .st { font-size: 10.5px; letter-spacing: 0.04em; }
|
||||||
|
.row .st.up { color: var(--up); }
|
||||||
|
.row .st.down { color: var(--down); }
|
||||||
|
|
||||||
|
/* ---------- url popover ---------- */
|
||||||
|
.urlpop {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 42px; z-index: 20;
|
||||||
|
min-width: 250px; padding: 4px;
|
||||||
|
background: var(--popover); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 10px 34px rgba(0,0,0,0.4);
|
||||||
|
opacity: 0; transform: translateY(-3px); pointer-events: none;
|
||||||
|
transition: opacity 120ms var(--ease), transform 120ms var(--ease);
|
||||||
|
}
|
||||||
|
.row.open .urlpop { opacity: 1; transform: none; pointer-events: auto; }
|
||||||
|
.urlpop a {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 7px 9px; border-radius: 4px;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 12px;
|
||||||
|
}
|
||||||
|
.urlpop a:hover { background: var(--sel); }
|
||||||
|
.urlpop .tag { color: var(--up); font-size: 10.5px; }
|
||||||
|
.urlpop .u { color: var(--text-faint); font-size: 11px; margin-left: auto; }
|
||||||
|
|
||||||
|
.foot { margin-top: 30px; font-size: 10.5px; color: var(--text-faint); text-align: center; }
|
||||||
|
|
||||||
|
@media (max-width: 860px) { .panels { grid-template-columns: 1fr 1fr; } .panel.wide { grid-column: 1 / -1; } }
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.topbar { padding: 0 14px; } .search { display: none; } .path { display: none; }
|
||||||
|
main { padding: 20px 14px 70px; }
|
||||||
|
.row { grid-template-columns: 18px 26px minmax(0,1fr) auto; }
|
||||||
|
.row .host { display: none; }
|
||||||
|
.panels { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
|
||||||
|
:focus-visible { outline: 1px solid var(--text); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">dash<span class="cursor"></span></div>
|
||||||
|
<span class="path">~/<b>board</b> · 17 services · 5 widgets</span>
|
||||||
|
<div class="search">/<span style="color:var(--text-dim)">search</span></div>
|
||||||
|
<button class="tbtn" id="theme">[t] theme</button>
|
||||||
|
<button class="tbtn primary">[+] add</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="banner">$ dash status --all
|
||||||
|
<b>17 services</b> indexed · <span class="ok">16 up</span> · <b>1 down</b> (radarr) · last sync 2s ago
|
||||||
|
type <b>/</b> to search · drag rows to reorder · click rows with multiple endpoints to choose</div>
|
||||||
|
|
||||||
|
<div class="panels">
|
||||||
|
<div class="panel wide">
|
||||||
|
<div class="phead">clock --local <span class="r">● live</span></div>
|
||||||
|
<div class="pbody">
|
||||||
|
<div class="clockv" id="clock">--:--:--</div>
|
||||||
|
<div class="tzrow"><span><b>PRG</b> <span id="tz1"></span></span><span><b>NYC</b> <span id="tz2"></span></span><span><b>TYO</b> <span id="tz3"></span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="phead"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()">pihole</div>
|
||||||
|
<div class="pbody"><div class="v">24.6%</div><div class="s">ads blocked · 84,213 q</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="phead"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()">adguard</div>
|
||||||
|
<div class="pbody"><div class="v">11.2%</div><div class="s">blocked · 31 clients</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="phead"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()">immich</div>
|
||||||
|
<div class="pbody"><div class="v">12,483</div><div class="s">photos · 96.4 GB</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="phead"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/truenas.png" alt="" onerror="this.remove()">storage</div>
|
||||||
|
<div class="pbody"><div class="v">61%</div><div class="s">14.2 / 23.2 TB</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="phead">uptime --30d</div>
|
||||||
|
<div class="pbody"><div class="v">99.7%</div><div class="s">1 incident · radarr</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="phead"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/qbittorrent.png" alt="" onerror="this.remove()">transfer</div>
|
||||||
|
<div class="pbody"><div class="v">42 MB/s</div><div class="s">↓ 38.4 · ↑ 3.6</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sechead"><span class="hash">##</span><h2>network</h2><span class="n">[4]</span>
|
||||||
|
<span class="fill">────────────────────────────────────────────</span>
|
||||||
|
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.2/admin"},{"tag":"ext","u":"https://pihole.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/pi-hole.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">pi-hole</span><span class="host">10.0.0.2 · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.3:3000"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="A"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/adguard-home.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">adguard-home</span><span class="host">10.0.0.3:3000</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.2:81"},{"tag":"ext","u":"https://npm.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nginx-proxy-manager.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">nginx-proxy-mgr</span><span class="host">10.0.0.2:81 · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.5:3001"},{"tag":"ext","u":"https://status.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="U"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/uptime-kuma.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">uptime-kuma</span><span class="host">status.tdvorak.dev · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sechead"><span class="hash">##</span><h2>media</h2><span class="n">[6]</span>
|
||||||
|
<span class="fill">────────────────────────────────────────────</span>
|
||||||
|
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.10:8096"},{"tag":"ext","u":"https://watch.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="J"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">jellyfin</span><span class="host">watch.tdvorak.dev · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.10:2283"},{"tag":"ext","u":"https://photos.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="I"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/immich.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">immich</span><span class="host">photos.tdvorak.dev · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.10:4533"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">navidrome</span><span class="host">10.0.0.10:4533</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.10:8080"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="q"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/qbittorrent.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">qbittorrent</span><span class="host">10.0.0.10:8080</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.10:8989"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="S"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/sonarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">sonarr</span><span class="host">10.0.0.10:8989</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.10:7878"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="R"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/radarr.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">radarr</span><span class="host">10.0.0.10:7878</span>
|
||||||
|
<span class="st down">[ down ]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sechead"><span class="hash">##</span><h2>infrastructure</h2><span class="n">[7]</span>
|
||||||
|
<span class="fill">────────────────────────────────────────────</span>
|
||||||
|
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"https://10.0.0.4:8006"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/proxmox.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">proxmox</span><span class="host">10.0.0.4:8006</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.4:9000"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="P"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/portainer.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">portainer</span><span class="host">10.0.0.4:9000</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.6"},{"tag":"ext","u":"https://nas.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="T"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/truenas.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">truenas</span><span class="host">nas.tdvorak.dev · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.4:3100"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="G"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/grafana.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">grafana</span><span class="host">10.0.0.4:3100</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"ext","u":"https://vault.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="V"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/vaultwarden.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">vaultwarden</span><span class="host">vault.tdvorak.dev</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"local","u":"http://10.0.0.7:8123"},{"tag":"ext","u":"https://home.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="H"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/home-assistant.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">home-assistant</span><span class="host">home.tdvorak.dev · 2 endpoints</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" draggable="true" data-urls='[{"tag":"ext","u":"https://cloud.tdvorak.dev"}]'>
|
||||||
|
<span class="prompt">›</span>
|
||||||
|
<div class="icon" data-letter="N"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nextcloud.png" alt="" onerror="this.remove()"></div>
|
||||||
|
<span class="name">nextcloud</span><span class="host">cloud.tdvorak.dev</span>
|
||||||
|
<span class="st up">[ up ]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="foot">mockup F — console · [t] toggles theme · rows with “2 endpoints” open the chooser</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('theme').onclick = () => {
|
||||||
|
const h = document.documentElement;
|
||||||
|
h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (tz, s) => new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: s ? '2-digit' : undefined, timeZone: tz });
|
||||||
|
function tick() {
|
||||||
|
const n = new Date();
|
||||||
|
document.getElementById('clock').textContent = fmt(undefined, true).format(n);
|
||||||
|
document.getElementById('tz1').textContent = fmt('Europe/Prague').format(n);
|
||||||
|
document.getElementById('tz2').textContent = fmt('America/New_York').format(n);
|
||||||
|
document.getElementById('tz3').textContent = fmt('Asia/Tokyo').format(n);
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 1000);
|
||||||
|
|
||||||
|
document.querySelectorAll('.sechead').forEach(h =>
|
||||||
|
h.addEventListener('click', () => h.parentElement.classList.toggle('closed')));
|
||||||
|
|
||||||
|
document.querySelectorAll('.row[data-urls]').forEach(card => {
|
||||||
|
const urls = JSON.parse(card.dataset.urls);
|
||||||
|
if (urls.length < 2) return;
|
||||||
|
const pop = card.querySelector('.urlpop');
|
||||||
|
pop.innerHTML = urls.map(x => `<a href="#" onclick="return false"><span class="tag">[${x.tag}]</span>${new URL(x.u).hostname}<span class="u">↗</span></a>`).join('');
|
||||||
|
card.addEventListener('click', e => {
|
||||||
|
if (card.classList.contains('dragging')) return;
|
||||||
|
document.querySelectorAll('.row.open').forEach(c => c !== card && c.classList.remove('open'));
|
||||||
|
card.classList.toggle('open');
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => document.querySelectorAll('.row.open').forEach(c => c.classList.remove('open')));
|
||||||
|
|
||||||
|
let dragEl = null;
|
||||||
|
document.querySelectorAll('.row').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', () => { dragEl = card; setTimeout(() => card.classList.add('dragging')); });
|
||||||
|
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||||||
|
card.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (card === dragEl) return;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
const before = e.clientY < r.top + r.height / 2;
|
||||||
|
card.parentElement.insertBefore(dragEl, before ? card : card.nextSibling);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.table').forEach(b => b.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragEl && e.target === b) b.appendChild(dragEl);
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+575
@@ -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/<file> 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"
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+4250
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<paths>({ baseUrl });
|
||||||
|
}
|
||||||
@@ -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..
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<Variable>(),
|
||||||
|
): 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<Variable>(),
|
||||||
|
): 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",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string> = 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<typeof resolvedVariableForIdentifier>[0],
|
||||||
|
boundary: ESTree.Node | null,
|
||||||
|
visitedVariables: ReadonlySet<Variable>,
|
||||||
|
): 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<typeof resolvedVariableForIdentifier>[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<typeof resolvedVariableForIdentifier>[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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string, RegExp>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string, ESTree.TSType>;
|
||||||
|
|
||||||
|
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<string, readonly ESTree.TSInterfaceDeclaration[]>;
|
||||||
|
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<Record<string, readonly string[]>>,
|
||||||
|
): TypeEnvironment {
|
||||||
|
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
|
||||||
|
|
||||||
|
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<string> = 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<string>,
|
||||||
|
): 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<string>,
|
||||||
|
): 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<string> = 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<string>,
|
||||||
|
): 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||||
|
|
||||||
|
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<string>,
|
||||||
|
): void {
|
||||||
|
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
|
||||||
|
const record = node as unknown as Readonly<Record<string, unknown>>;
|
||||||
|
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<string> {
|
||||||
|
const names = new Set<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
|
||||||
|
|
||||||
|
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||||
|
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<string, Substitution>;
|
||||||
|
|
||||||
|
export type TypeAliasEnvironment = {
|
||||||
|
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
|
||||||
|
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
|
||||||
|
readonly visitorKeys: VisitorKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedTypeMatcher = (
|
||||||
|
type: ESTree.TSType,
|
||||||
|
matches: (child: ESTree.TSType) => boolean,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
|
||||||
|
|
||||||
|
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<string, TypeBinding[]>,
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
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<string, TypeBinding[]>();
|
||||||
|
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<ESTree.TSTypeAliasDeclaration>,
|
||||||
|
): 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());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user