mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
docs: add design spec, roadmap, and three UI mockups
Captures product requirements from prompt.md, approves SQLite over Postgres for single-binary deployment, and sketches the Go+React architecture, data model, API surface, and phased roadmap to OSS v1.0. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e42a6cfc61
commit
420a2b9524
5 files changed
+1498
No files matched your search
@@ -0,0 +1,141 @@
|
|||||||
|
# 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 direction — pending
|
||||||
|
|
||||||
|
Three static mockups in `mockups/` — open each, compare:
|
||||||
|
|
||||||
|
| File | Direction | Character |
|
||||||
|
|---|---|---|
|
||||||
|
| `a-vercel-mono.html` | Flat monochrome | Geist, hairlines, no shadows, quietest |
|
||||||
|
| `b-command-bento.html` | Bento grid | mixed tile sizes, widgets inline, densest |
|
||||||
|
| `c-casa-glass.html` | Glass + wallpaper | homescreen tiles, warmest, most motion |
|
||||||
|
|
||||||
|
Decision recorded here once picked. A and C share card anatomy — hybrid (mono base + optional wallpaper) is cheap later.
|
||||||
|
|
||||||
|
## 8. Roadmap
|
||||||
|
|
||||||
|
### Phase 0 — Foundation
|
||||||
|
- [ ] Monorepo scaffold, LICENSE (MIT), README, .gitignore, AGENTS.md
|
||||||
|
- [ ] `openapi.yaml` stub → generated TS client pipeline
|
||||||
|
- [ ] CI: `go build`, `go vet`, `tsc --noEmit`, oxlint + vendored anti-slop rules
|
||||||
|
- [ ] `docker compose up` runs the app
|
||||||
|
|
||||||
|
### Phase 1 — MVP
|
||||||
|
- [ ] Schema + goose migrations; section/item/URL CRUD
|
||||||
|
- [ ] Board UI: sections, cards, collapse, drag-drop ordering (persist position)
|
||||||
|
- [ ] Multi-URL popover; icon URL + upload
|
||||||
|
- [ ] Add/Edit dialogs; delete with confirm
|
||||||
|
- [ ] Dark/light; responsive to phone width
|
||||||
|
- [ ] Single-image Dockerfile; JSON export/import
|
||||||
|
|
||||||
|
### Phase 2 — Widgets
|
||||||
|
- [ ] Widget registry (Go) + `WidgetHost` (React)
|
||||||
|
- [ ] Clock/timezone widget (frontend-only)
|
||||||
|
- [ ] Pi-hole, AdGuard Home, Immich fetchers + per-widget config UI
|
||||||
|
- [ ] Service up/down ping (server-side HEAD, cached)
|
||||||
|
|
||||||
|
### Phase 3 — Open-source readiness
|
||||||
|
- [ ] Docs (install, config, widget dev guide), screenshots, demo GIF
|
||||||
|
- [ ] dashboard-icons auto-suggest in icon picker
|
||||||
|
- [ ] Importers: Homepage `services.yml`, Homarr JSON, Dashy `conf.yml`
|
||||||
|
- [ ] ⌘K search palette; i18n scaffolding
|
||||||
|
- [ ] goreleaser (binaries) + GHCR image + semver tags + CHANGELOG
|
||||||
|
- [ ] Issue/PR templates, CONTRIBUTING.md, code of conduct
|
||||||
|
|
||||||
|
### Phase 4 — Post-1.0 (demand-driven only)
|
||||||
|
- Optional auth (local + OIDC), health-history graphs, custom CSS/themes, iframe widgets, PWA, wallpaper support for direction A
|
||||||
|
|
||||||
|
### 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,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,449 @@
|
|||||||
|
<!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 — B · Command Bento</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/space-grotesk@latest/index.css">
|
||||||
|
<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: #0e0e11;
|
||||||
|
--bg-tint: radial-gradient(1200px 500px at 70% -10%, rgba(59,130,246,0.07), transparent 60%);
|
||||||
|
--surface: #16161a;
|
||||||
|
--surface-2: #1c1c21;
|
||||||
|
--border: rgba(255,255,255,0.07);
|
||||||
|
--border-strong: rgba(255,255,255,0.15);
|
||||||
|
--text: #f4f4f5;
|
||||||
|
--text-dim: #a1a1aa;
|
||||||
|
--text-faint: #5b5b64;
|
||||||
|
--up: #34d399;
|
||||||
|
--down: #fbbf24;
|
||||||
|
--acc-net: #60a5fa;
|
||||||
|
--acc-med: #c084fc;
|
||||||
|
--acc-inf: #f59e0b;
|
||||||
|
--acc-wdg: #34d399;
|
||||||
|
--icon-bg: #202024;
|
||||||
|
--popover: #1a1a1f;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f4f4f6;
|
||||||
|
--bg-tint: radial-gradient(1200px 500px at 70% -10%, rgba(59,130,246,0.08), transparent 60%);
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f7f7f9;
|
||||||
|
--border: rgba(0,0,0,0.08);
|
||||||
|
--border-strong: rgba(0,0,0,0.16);
|
||||||
|
--text: #18181b;
|
||||||
|
--text-dim: #52525b;
|
||||||
|
--text-faint: #9d9da6;
|
||||||
|
--up: #059669;
|
||||||
|
--down: #d97706;
|
||||||
|
--acc-net: #2563eb;
|
||||||
|
--acc-med: #9333ea;
|
||||||
|
--acc-inf: #d97706;
|
||||||
|
--acc-wdg: #059669;
|
||||||
|
--icon-bg: #eef0f2;
|
||||||
|
--popover: #ffffff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body {
|
||||||
|
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background: var(--bg); background-image: var(--bg-tint); background-attachment: fixed;
|
||||||
|
color: var(--text); min-height: 100dvh;
|
||||||
|
transition: background 200ms var(--ease), color 200ms var(--ease);
|
||||||
|
}
|
||||||
|
.mono { font-family: "JetBrains Mono", ui-monospace, monospace; }
|
||||||
|
|
||||||
|
/* ---------- top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 78%, transparent);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 16px; letter-spacing: -0.02em; }
|
||||||
|
.brand .mark { width: 22px; height: 22px; border-radius: 6px; background: linear-gradient(135deg, var(--acc-net), var(--acc-med)); display: grid; place-items: center; color: #fff; }
|
||||||
|
.brand .mark svg { display: block; }
|
||||||
|
.crumb { font-size: 12px; color: var(--text-faint); }
|
||||||
|
.crumb b { color: var(--text-dim); font-weight: 500; }
|
||||||
|
.search {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 220px; padding: 7px 12px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text-faint); font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.search kbd { margin-left: auto; font-family: "JetBrains Mono", monospace; font-size: 10.5px; 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: 12.5px; font-weight: 600;
|
||||||
|
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 grid ---------- */
|
||||||
|
main { max-width: 1180px; margin: 0 auto; padding: 32px 28px 90px; }
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 4px 2px; margin-top: 18px;
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.section-label:first-child { margin-top: 0; }
|
||||||
|
.section-label .swatch { width: 8px; height: 8px; border-radius: 3px; }
|
||||||
|
.section-label h2 { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; }
|
||||||
|
.section-label .count { font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--text-faint); }
|
||||||
|
.section-label .line { flex: 1; height: 1px; background: var(--border); }
|
||||||
|
.section-label .chev { color: var(--text-faint); transition: transform 200ms var(--ease); }
|
||||||
|
.collapsed-icon { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
.bento { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
|
||||||
|
.bento.hide > *:not(.section-label) { display: none; }
|
||||||
|
|
||||||
|
/* ---------- service tile ---------- */
|
||||||
|
.tile {
|
||||||
|
grid-column: span 2;
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px 16px; min-height: 72px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: border-color 150ms var(--ease), background 150ms var(--ease), transform 100ms var(--ease);
|
||||||
|
}
|
||||||
|
.tile:hover { border-color: var(--border-strong); background: var(--surface-2); }
|
||||||
|
.tile:active { transform: scale(0.98); }
|
||||||
|
.tile.dragging { opacity: 0.35; }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative; flex: none;
|
||||||
|
width: 42px; height: 42px; border-radius: 11px;
|
||||||
|
background: var(--icon-bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px; 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: 6px; background: var(--icon-bg); }
|
||||||
|
|
||||||
|
.tile .txt { min-width: 0; }
|
||||||
|
.tile .name { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tile .host { font-family: "JetBrains 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: "JetBrains Mono", monospace; font-size: 9.5px; font-weight: 500;
|
||||||
|
padding: 3px 7px; border-radius: 5px; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.chip.up { color: var(--up); background: color-mix(in srgb, var(--up) 12%, transparent); }
|
||||||
|
.chip.down { color: var(--down); background: color-mix(in srgb, var(--down) 12%, transparent); }
|
||||||
|
|
||||||
|
/* ---------- widget tiles ---------- */
|
||||||
|
.widget {
|
||||||
|
grid-column: span 3; grid-row: span 2;
|
||||||
|
padding: 18px; border-radius: 14px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; min-height: 154px;
|
||||||
|
}
|
||||||
|
.widget.s2 { grid-column: span 2; }
|
||||||
|
.wlabel { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-faint); display: flex; align-items: center; gap: 7px; }
|
||||||
|
.wlabel img { width: 14px; height: 14px; }
|
||||||
|
.wlabel .live { margin-left: auto; color: var(--up); font-family: "JetBrains Mono", monospace; font-size: 9px; letter-spacing: 0.1em; text-transform: none; }
|
||||||
|
.clock { font-family: "JetBrains Mono", monospace; font-size: 34px; font-weight: 500; letter-spacing: -0.03em; margin-top: auto; font-variant-numeric: tabular-nums; }
|
||||||
|
.zones { display: flex; gap: 16px; margin-top: 8px; font-family: "JetBrains 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: "JetBrains Mono", monospace; font-size: 30px; 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: 34px; margin-top: 12px; }
|
||||||
|
.bars i { flex: 1; border-radius: 2px; background: color-mix(in srgb, var(--acc-wdg) 55%, transparent); }
|
||||||
|
.bars i.dim { background: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ---------- url popover ---------- */
|
||||||
|
.urlpop {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 14px; z-index: 20;
|
||||||
|
min-width: 240px; padding: 5px;
|
||||||
|
background: var(--popover); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 2px 6px 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: 8px;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 13px;
|
||||||
|
}
|
||||||
|
.urlpop a:hover { background: var(--surface-2); }
|
||||||
|
.urlpop .tag {
|
||||||
|
flex: none; font-family: "JetBrains Mono", monospace; font-size: 9px; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding: 3px 6px; border-radius: 5px;
|
||||||
|
background: var(--icon-bg); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.urlpop .u { font-family: "JetBrains Mono", monospace; font-size: 10.5px; color: var(--text-faint); margin-left: auto; }
|
||||||
|
|
||||||
|
.edit-hint { color: var(--text-faint); font-size: 12px; text-align: center; margin-top: 32px; }
|
||||||
|
|
||||||
|
@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: 20px 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(--acc-net); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark"><svg width="12" height="12" viewBox="0 0 18 18" fill="none"><rect x="1" y="1" width="7" height="7" rx="1.5" fill="#fff"/><rect x="10" y="1" width="7" height="7" rx="1.5" fill="#fff" opacity=".5"/><rect x="1" y="10" width="7" height="7" rx="1.5" fill="#fff" opacity=".5"/><rect x="10" y="10" width="7" height="7" rx="1.5" fill="#fff"/></svg></span>
|
||||||
|
Dash
|
||||||
|
</div>
|
||||||
|
<span class="crumb">home / <b>main board</b></span>
|
||||||
|
<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
|
||||||
|
<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.4"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="bento">
|
||||||
|
|
||||||
|
<!-- widgets row -->
|
||||||
|
<div class="section-label" data-sec>
|
||||||
|
<span class="swatch" style="background:var(--acc-wdg)"></span>
|
||||||
|
<h2>Status</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="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 class="dim" style="height:30%"></i><i style="height:45%"></i><i class="dim" style="height:35%"></i><i style="height:60%"></i><i style="height:52%"></i><i class="dim" style="height:40%"></i><i style="height:75%"></i><i style="height:64%"></i><i class="dim" style="height:48%"></i><i style="height:82%"></i><i style="height:70%"></i><i 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>
|
||||||
|
|
||||||
|
<!-- network -->
|
||||||
|
<div class="section-label" data-sec>
|
||||||
|
<span class="swatch" style="background:var(--acc-net)"></span>
|
||||||
|
<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">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">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">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">UP</span>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- media -->
|
||||||
|
<div class="section-label" data-sec>
|
||||||
|
<span class="swatch" style="background:var(--acc-med)"></span>
|
||||||
|
<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">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">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">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">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">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>
|
||||||
|
|
||||||
|
<!-- infra -->
|
||||||
|
<div class="section-label" data-sec>
|
||||||
|
<span class="swatch" style="background:var(--acc-inf)"></span>
|
||||||
|
<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">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">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">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">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">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">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">UP</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="edit-hint">Mockup B — Command Bento. Widgets and services share one grid. Click a multi-URL tile (Jellyfin, Immich…) for the chooser. Section headers collapse.</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);
|
||||||
|
|
||||||
|
// collapse: hide siblings until next section-label
|
||||||
|
document.querySelectorAll('[data-sec]').forEach(h => h.addEventListener('click', () => {
|
||||||
|
h.querySelector('.chev').classList.toggle('collapsed-icon');
|
||||||
|
let el = h.nextElementSibling;
|
||||||
|
const hide = h.dataset.closed !== '1';
|
||||||
|
while (el && !el.hasAttribute('data-sec')) { el.style.display = hide ? 'none' : ''; el = el.nextElementSibling; }
|
||||||
|
h.dataset.closed = hide ? '1' : '';
|
||||||
|
}));
|
||||||
|
|
||||||
|
// multi-URL popover
|
||||||
|
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')));
|
||||||
|
|
||||||
|
// drag reorder
|
||||||
|
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,466 @@
|
|||||||
|
<!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 — C · Casa Glass</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/css/plus-jakarta-sans@latest/index.css">
|
||||||
|
<style>
|
||||||
|
:root { --spring: cubic-bezier(0.32, 0.72, 0, 1); }
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--wall:
|
||||||
|
radial-gradient(1000px 700px at 15% -10%, rgba(99,102,241,0.55), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 85% 10%, rgba(16,185,129,0.35), transparent 55%),
|
||||||
|
radial-gradient(800px 900px at 50% 110%, rgba(168,85,247,0.4), transparent 60%),
|
||||||
|
linear-gradient(180deg, #0b0d1a, #101223);
|
||||||
|
--glass: rgba(255,255,255,0.07);
|
||||||
|
--glass-strong: rgba(255,255,255,0.11);
|
||||||
|
--glass-border: rgba(255,255,255,0.13);
|
||||||
|
--glass-border-hi: rgba(255,255,255,0.25);
|
||||||
|
--inner-hi: inset 0 1px 0 rgba(255,255,255,0.12);
|
||||||
|
--text: #f4f5fa;
|
||||||
|
--text-dim: rgba(235,238,250,0.72);
|
||||||
|
--text-faint: rgba(235,238,250,0.45);
|
||||||
|
--up: #4ade80;
|
||||||
|
--down: #fbbf24;
|
||||||
|
--icon-bg: rgba(255,255,255,0.12);
|
||||||
|
--popover: rgba(22,24,40,0.82);
|
||||||
|
--shadow: 0 12px 40px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--wall:
|
||||||
|
radial-gradient(1000px 700px at 15% -10%, rgba(147,197,253,0.7), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 85% 10%, rgba(167,243,208,0.7), transparent 55%),
|
||||||
|
radial-gradient(800px 900px at 50% 110%, rgba(216,180,254,0.7), transparent 60%),
|
||||||
|
linear-gradient(180deg, #eef1fb, #f6f3fc);
|
||||||
|
--glass: rgba(255,255,255,0.45);
|
||||||
|
--glass-strong: rgba(255,255,255,0.62);
|
||||||
|
--glass-border: rgba(255,255,255,0.65);
|
||||||
|
--glass-border-hi: rgba(255,255,255,0.9);
|
||||||
|
--inner-hi: inset 0 1px 0 rgba(255,255,255,0.7);
|
||||||
|
--text: #1a1c2e;
|
||||||
|
--text-dim: rgba(26,28,46,0.7);
|
||||||
|
--text-faint: rgba(26,28,46,0.45);
|
||||||
|
--up: #16a34a;
|
||||||
|
--down: #d97706;
|
||||||
|
--icon-bg: rgba(255,255,255,0.55);
|
||||||
|
--popover: rgba(255,255,255,0.85);
|
||||||
|
--shadow: 0 12px 40px rgba(60,60,120,0.15);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body {
|
||||||
|
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
color: var(--text); min-height: 100dvh;
|
||||||
|
transition: color 200ms var(--spring);
|
||||||
|
}
|
||||||
|
.wall { position: fixed; inset: 0; z-index: -1; background: var(--wall); }
|
||||||
|
.wall::after { /* subtle grain */
|
||||||
|
content: ""; position: absolute; inset: 0; opacity: 0.04; pointer-events: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- floating glass bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 14px; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin: 14px auto 0; max-width: 1080px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.5);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.5);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--inner-hi), var(--shadow);
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; letter-spacing: -0.01em; }
|
||||||
|
.brand .mark { width: 26px; height: 26px; border-radius: 8px; display: grid; place-items: center; background: var(--glass-strong); border: 1px solid var(--glass-border); box-shadow: var(--inner-hi); }
|
||||||
|
.search {
|
||||||
|
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 210px; padding: 8px 12px;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 999px; color: var(--text-faint); font-size: 13px;
|
||||||
|
box-shadow: var(--inner-hi);
|
||||||
|
}
|
||||||
|
.pillbtn {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
height: 36px; border-radius: 999px; border: 1px solid var(--glass-border);
|
||||||
|
background: var(--glass); color: var(--text-dim);
|
||||||
|
font-family: inherit; font-size: 13px; font-weight: 600;
|
||||||
|
cursor: pointer; padding: 0 12px; box-shadow: var(--inner-hi);
|
||||||
|
transition: background 200ms var(--spring), color 200ms var(--spring), transform 120ms var(--spring);
|
||||||
|
}
|
||||||
|
.pillbtn.icon { width: 36px; padding: 0; }
|
||||||
|
.pillbtn:hover { background: var(--glass-strong); color: var(--text); }
|
||||||
|
.pillbtn:active { transform: scale(0.96); }
|
||||||
|
.pillbtn.primary { background: var(--text); color: var(--popover); border: none; padding: 0 16px; }
|
||||||
|
|
||||||
|
/* ---------- hero ---------- */
|
||||||
|
main { max-width: 1080px; margin: 0 auto; padding: 40px 24px 100px; }
|
||||||
|
.hero {
|
||||||
|
padding: 34px 38px; margin-bottom: 34px;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 28px; box-shadow: var(--inner-hi), var(--shadow);
|
||||||
|
display: flex; align-items: flex-end; gap: 24px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hero .greet { font-size: 15px; font-weight: 600; color: var(--text-dim); margin-bottom: 6px; }
|
||||||
|
.hero .clock { font-size: 58px; font-weight: 800; letter-spacing: -0.04em; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
|
.hero .date { font-size: 14px; color: var(--text-faint); margin-top: 8px; font-weight: 500; }
|
||||||
|
.hero .zones { margin-left: auto; display: flex; gap: 10px; }
|
||||||
|
.zone {
|
||||||
|
padding: 10px 16px; border-radius: 16px; text-align: center;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border); box-shadow: var(--inner-hi);
|
||||||
|
}
|
||||||
|
.zone .zname { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-faint); }
|
||||||
|
.zone .ztime { font-size: 17px; font-weight: 700; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------- section trays (double bezel) ---------- */
|
||||||
|
.tray {
|
||||||
|
padding: 10px; margin-bottom: 26px;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 26px; box-shadow: var(--inner-hi), var(--shadow);
|
||||||
|
}
|
||||||
|
.tray-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px 12px; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.tray-head h2 { font-size: 13px; font-weight: 700; letter-spacing: -0.01em; }
|
||||||
|
.tray-head .count { font-size: 11px; font-weight: 600; color: var(--text-faint); background: var(--glass); border: 1px solid var(--glass-border); padding: 2px 8px; border-radius: 999px; }
|
||||||
|
.tray-head .chev { margin-left: auto; color: var(--text-faint); transition: transform 250ms var(--spring); }
|
||||||
|
.tray.closed .chev { transform: rotate(-90deg); }
|
||||||
|
.tray-body {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 8px;
|
||||||
|
background: rgba(0,0,0,0.12);
|
||||||
|
border-radius: 18px; padding: 12px;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .tray-body { background: rgba(255,255,255,0.4); }
|
||||||
|
.tray.closed .tray-body { display: none; }
|
||||||
|
|
||||||
|
/* ---------- app tile (homescreen) ---------- */
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||||
|
padding: 18px 10px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: grab; text-align: center;
|
||||||
|
transition: background 250ms var(--spring), border-color 250ms var(--spring), transform 250ms var(--spring);
|
||||||
|
}
|
||||||
|
.tile:hover { background: var(--glass); border-color: var(--glass-border); transform: translateY(-3px); }
|
||||||
|
.tile:active { transform: scale(0.96); }
|
||||||
|
.tile.dragging { opacity: 0.35; }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
width: 56px; height: 56px; border-radius: 16px;
|
||||||
|
background: var(--icon-bg); border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--inner-hi), 0 6px 18px rgba(0,0,0,0.18);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px; font-weight: 800; color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 250ms var(--spring);
|
||||||
|
}
|
||||||
|
.tile:hover .icon { transform: scale(1.06); }
|
||||||
|
.icon::before { content: attr(data-letter); }
|
||||||
|
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; padding: 8px; background: var(--icon-bg); }
|
||||||
|
|
||||||
|
.tile .name { font-size: 12.5px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||||
|
.tile .meta { display: flex; align-items: center; gap: 6px; margin-top: -4px; }
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--up); box-shadow: 0 0 8px var(--up); }
|
||||||
|
.dot.down { background: var(--down); box-shadow: 0 0 8px var(--down); }
|
||||||
|
.multi { font-size: 9.5px; font-weight: 700; color: var(--text-faint); background: var(--glass); border: 1px solid var(--glass-border); border-radius: 999px; padding: 1px 6px; }
|
||||||
|
|
||||||
|
/* ---------- widget strip ---------- */
|
||||||
|
.wstrip { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
|
||||||
|
.widget {
|
||||||
|
padding: 18px 20px; border-radius: 20px;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--inner-hi);
|
||||||
|
}
|
||||||
|
.wlabel { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-faint); display: flex; align-items: center; gap: 7px; }
|
||||||
|
.wlabel img { width: 14px; height: 14px; }
|
||||||
|
.stat { font-size: 26px; font-weight: 800; letter-spacing: -0.03em; margin-top: 10px; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat-sub { font-size: 11.5px; color: var(--text-faint); margin-top: 3px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* ---------- url popover ---------- */
|
||||||
|
.urlpop {
|
||||||
|
position: absolute; top: calc(100% + 8px); left: 50%; z-index: 20;
|
||||||
|
transform: translateX(-50%) translateY(-4px) scale(0.97);
|
||||||
|
min-width: 220px; padding: 6px;
|
||||||
|
background: var(--popover);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.5);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(1.5);
|
||||||
|
border: 1px solid var(--glass-border-hi);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--inner-hi), 0 16px 50px rgba(0,0,0,0.4);
|
||||||
|
opacity: 0; pointer-events: none;
|
||||||
|
transition: opacity 200ms var(--spring), transform 200ms var(--spring);
|
||||||
|
}
|
||||||
|
.tile.open .urlpop { opacity: 1; transform: translateX(-50%); pointer-events: auto; }
|
||||||
|
.urlpop a {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 9px 11px; border-radius: 10px;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.urlpop a:hover { background: var(--glass); }
|
||||||
|
.urlpop .tag {
|
||||||
|
flex: none; font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.09em;
|
||||||
|
padding: 3px 7px; border-radius: 999px;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.urlpop .u { font-size: 11px; color: var(--text-faint); margin-left: auto; }
|
||||||
|
|
||||||
|
.edit-hint { color: var(--text-faint); font-size: 12px; text-align: center; margin-top: 30px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar { margin: 10px 12px 0; padding: 8px 12px; }
|
||||||
|
.search { display: none; }
|
||||||
|
main { padding: 26px 14px 80px; }
|
||||||
|
.hero { padding: 26px 22px; } .hero .clock { font-size: 42px; }
|
||||||
|
.hero .zones { margin-left: 0; }
|
||||||
|
.tray-body { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
|
||||||
|
.icon { width: 48px; height: 48px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||||
|
:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; border-radius: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wall"></div>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark"><svg width="13" height="13" viewBox="0 0 18 18" fill="none"><rect x="1" y="1" width="7" height="7" rx="2" fill="currentColor"/><rect x="10" y="1" width="7" height="7" rx="2" fill="currentColor" opacity=".5"/><rect x="1" y="10" width="7" height="7" rx="2" fill="currentColor" opacity=".5"/><rect x="10" y="10" width="7" height="7" rx="2" fill="currentColor"/></svg></span>
|
||||||
|
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
|
||||||
|
</div>
|
||||||
|
<button class="pillbtn icon" 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="pillbtn primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="hero">
|
||||||
|
<div>
|
||||||
|
<div class="greet" id="greet">Good evening</div>
|
||||||
|
<div class="clock" id="clock">--:--</div>
|
||||||
|
<div class="date" id="date"></div>
|
||||||
|
</div>
|
||||||
|
<div class="zones">
|
||||||
|
<div class="zone"><div class="zname">Prague</div><div class="ztime" id="tz1">--:--</div></div>
|
||||||
|
<div class="zone"><div class="zname">New York</div><div class="ztime" id="tz2">--:--</div></div>
|
||||||
|
<div class="zone"><div class="zname">Tokyo</div><div class="ztime" id="tz3">--:--</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray" style="margin-bottom:26px">
|
||||||
|
<div class="tray-head"><h2>Widgets</h2><span class="count">4</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="tray-body" style="display:block">
|
||||||
|
<div class="wstrip">
|
||||||
|
<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 today · 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 Home</div>
|
||||||
|
<div class="stat">11.2%</div>
|
||||||
|
<div class="stat-sub">blocked · 31 clients online</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 library</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget">
|
||||||
|
<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="stat">61%</div>
|
||||||
|
<div class="stat-sub">14.2 of 23.2 TB used</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray">
|
||||||
|
<div class="tray-head"><h2>Network</h2><span class="count">4</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="tray-body">
|
||||||
|
<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="name">Pi-hole</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<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="name">AdGuard Home</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">Nginx Proxy Mgr</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<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="name">Uptime Kuma</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<div class="urlpop"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray">
|
||||||
|
<div class="tray-head"><h2>Media</h2><span class="count">6</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="tray-body">
|
||||||
|
<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="name">Jellyfin</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<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="name">Immich</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<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="name">Navidrome</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">qBittorrent</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">Sonarr</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">Radarr</div>
|
||||||
|
<div class="meta"><span class="dot down"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray">
|
||||||
|
<div class="tray-head"><h2>Infrastructure</h2><span class="count">7</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="tray-body">
|
||||||
|
<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="name">Proxmox</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">Portainer</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">TrueNAS</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<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="name">Grafana</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">Vaultwarden</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</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="name">Home Assistant</div>
|
||||||
|
<div class="meta"><span class="dot"></span><span class="multi">2 URLs</span></div>
|
||||||
|
<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="name">Nextcloud</div>
|
||||||
|
<div class="meta"><span class="dot"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="edit-hint">Mockup C — Casa Glass. Wallpaper + glass trays + homescreen tiles. Click a “2 URLs” tile for the chooser. Trays collapse.</p>
|
||||||
|
</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('date').textContent = n.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
const h = n.getHours();
|
||||||
|
document.getElementById('greet').textContent = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 10000);
|
||||||
|
|
||||||
|
document.querySelectorAll('.tray-head').forEach(h =>
|
||||||
|
h.addEventListener('click', () => h.parentElement.classList.toggle('closed')));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tray-body').forEach(b => b.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragEl && e.target === b) b.appendChild(dragEl);
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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..
|
||||||
Reference in new issue
Block a user