mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat(landing): rebuild as Next.js app with live product mocks
Replaces the static HTML page with a Next.js 15 + Tailwind v4 + Motion app. The hero renders the dashboard itself - live tiles, ticking clock, wandering metrics, pulsing status dots - instead of screenshots. Adds a playable four-renderer switcher (bento/cards/index/console) with layout animations, live monitor/domain/system mocks, and a cycling alert feed. Vercel project dash is now git-connected with root dir landing/; pushes deploy automatically. Canonical alias: dash-homelab.vercel.app. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e88429514b
commit
d1486d5ec8
30 files changed
+3099
-466
No files matched your search
@@ -28,3 +28,4 @@ dist/
|
|||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
.vercel
|
.vercel
|
||||||
|
.env*
|
||||||
@@ -9,7 +9,8 @@ Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
|
|||||||
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
|
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
|
||||||
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
|
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
|
||||||
- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract
|
- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract
|
||||||
- `landing/` — static marketing page, deployed to Vercel; no build step, plain HTML/CSS/JS
|
- `landing/` — Next.js 15 marketing site (own package.json, not a workspace);
|
||||||
|
Vercel project `dash`, root dir `landing`, git-connected to this repo
|
||||||
- `infra/` — Dockerfile (multi-stage, one image)
|
- `infra/` — Dockerfile (multi-stage, one image)
|
||||||
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
|
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
|
||||||
- `data/` — runtime SQLite + uploaded icons (gitignored)
|
- `data/` — runtime SQLite + uploaded icons (gitignored)
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
.vercel
|
.vercel
|
||||||
.env*
|
.env*
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,67 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Product tokens — same palette as the app (apps/frontend/src/index.css). */
|
||||||
|
@theme {
|
||||||
|
--color-bg: #0a0a0a;
|
||||||
|
--color-surface: #111111;
|
||||||
|
--color-surface-hover: #161616;
|
||||||
|
--color-line: rgba(255, 255, 255, 0.08);
|
||||||
|
--color-line-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--color-text: #fafafa;
|
||||||
|
--color-dim: #a1a1aa;
|
||||||
|
--color-faint: #52525b;
|
||||||
|
--color-accent: #3b82f6;
|
||||||
|
--color-up: #22c55e;
|
||||||
|
--color-down: #f59e0b;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-iconbg: #1c1c1e;
|
||||||
|
|
||||||
|
--ease-snap: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
|
||||||
|
--font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
|
--animate-pulse-dot: pulse-dot 2.4s ease-in-out infinite;
|
||||||
|
--animate-float: float 7s ease-in-out infinite;
|
||||||
|
--animate-scan: scan 5s linear infinite;
|
||||||
|
--animate-draw: draw 2.2s var(--ease-snap) forwards;
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); }
|
||||||
|
50% { opacity: 0.75; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
@keyframes scan {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(400%); }
|
||||||
|
}
|
||||||
|
@keyframes draw {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geist = localFont({
|
||||||
|
src: "./fonts/geist-var.woff2",
|
||||||
|
variable: "--font-geist",
|
||||||
|
weight: "100 900",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = localFont({
|
||||||
|
src: "./fonts/geist-mono-var.woff2",
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
weight: "100 900",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL("https://dash-homelab.vercel.app"),
|
||||||
|
title: "Dash - Self-hosted homelab dashboard",
|
||||||
|
description:
|
||||||
|
"Service boards, uptime monitors, domain watch, and system metrics in one container. A single static Go binary with an embedded React UI. No YAML, no external database.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Dash - Self-hosted homelab dashboard",
|
||||||
|
description:
|
||||||
|
"Service boards, uptime monitors, domain watch, and system metrics in one container. One static Go binary, no YAML.",
|
||||||
|
type: "website",
|
||||||
|
images: ["/dash.svg"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0a0a0a",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||||
|
<body className="font-sans">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Nav } from "../components/Nav";
|
||||||
|
import { Board } from "../components/Board";
|
||||||
|
import { RendererDemo } from "../components/RendererDemo";
|
||||||
|
import { MonitorMock, DomainMock, SystemMock, StatusBand } from "../components/Mocks";
|
||||||
|
import { Install } from "../components/Install";
|
||||||
|
import { CopyButton } from "../components/CopyButton";
|
||||||
|
import { Reveal } from "../components/Reveal";
|
||||||
|
|
||||||
|
function SectionHead({ label, tag }: { label: string; tag: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-7 flex items-center gap-3">
|
||||||
|
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-dim">
|
||||||
|
{label}
|
||||||
|
</h2>
|
||||||
|
<span className="h-px flex-1 bg-line" />
|
||||||
|
<span className="font-mono text-[11px] text-faint">{tag}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feature({
|
||||||
|
flip = false,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
mock,
|
||||||
|
}: {
|
||||||
|
flip?: boolean;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
mock: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Reveal>
|
||||||
|
<div className="grid items-center gap-8 md:grid-cols-[5fr_7fr]">
|
||||||
|
<div className={flip ? "md:order-2" : ""}>
|
||||||
|
<h3 className="text-[22px] font-medium tracking-tight">{title}</h3>
|
||||||
|
<p className="mt-2.5 max-w-[46ch] text-[14.5px] leading-relaxed text-dim">{children}</p>
|
||||||
|
</div>
|
||||||
|
<div className={flip ? "md:order-1" : ""}>{mock}</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
|
||||||
|
{/* ---------- hero ---------- */}
|
||||||
|
<header className="mx-auto max-w-5xl px-5 pt-20 sm:pt-24">
|
||||||
|
<Reveal>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 font-mono text-[11.5px] text-dim">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-up animate-pulse-dot" />
|
||||||
|
self-hosted · open source · MIT
|
||||||
|
</span>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.06}>
|
||||||
|
<h1 className="mt-6 max-w-[16ch] text-[42px] font-medium leading-[1.04] tracking-[-0.035em] sm:text-[64px]">
|
||||||
|
Your homelab, on one calm screen.
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.12}>
|
||||||
|
<p className="mt-5 max-w-[58ch] text-[16.5px] leading-relaxed text-dim">
|
||||||
|
Service boards, uptime monitors, domain watch, and system metrics - a
|
||||||
|
single static Go binary with the UI baked in. No YAML, no external
|
||||||
|
database, no mandatory accounts.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.18} className="mt-8 flex flex-wrap items-center gap-2.5">
|
||||||
|
<a
|
||||||
|
href="#install"
|
||||||
|
className="flex h-10 items-center rounded-lg bg-text px-4 text-[14px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Dvorinka/Dash"
|
||||||
|
className="flex h-10 items-center rounded-lg border border-line bg-surface px-4 text-[14px] font-medium text-dim transition-colors hover:border-line-strong hover:text-text"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<span className="flex h-10 items-center gap-1 rounded-lg border border-line bg-surface pl-3.5 pr-1.5 font-mono text-[12.5px]">
|
||||||
|
<span className="text-faint">$</span>
|
||||||
|
<code>docker run -p 3000:3000 ghcr.io/dvorinka/dash</code>
|
||||||
|
<CopyButton
|
||||||
|
text="docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest"
|
||||||
|
label="Copy docker command"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* the product, rendered live */}
|
||||||
|
<Reveal delay={0.24} className="relative mt-14">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -inset-x-8 -top-10 h-40 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(59,130,246,0.10),transparent)]"
|
||||||
|
/>
|
||||||
|
<Board />
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal className="mt-14 grid grid-cols-2 overflow-hidden rounded-xl border border-line sm:grid-cols-4">
|
||||||
|
{[
|
||||||
|
["Deploy", "one static binary"],
|
||||||
|
["Database", "SQLite, embedded"],
|
||||||
|
["Config", "UI-managed, no YAML"],
|
||||||
|
["License", "MIT"],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={k} className={`px-5 py-4 ${i > 0 ? "border-l border-line" : ""} ${i >= 2 ? "max-sm:border-t max-sm:border-line" : ""} ${i === 2 ? "max-sm:border-l-0" : ""}`}>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-faint">{k}</div>
|
||||||
|
<div className="mt-1.5 text-[14.5px] font-medium">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Reveal>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl px-5">
|
||||||
|
{/* ---------- renderers ---------- */}
|
||||||
|
<section id="renderers" className="scroll-mt-24 pt-24">
|
||||||
|
<SectionHead label="Boards" tag="the differentiator" />
|
||||||
|
<div className="grid items-center gap-8 md:grid-cols-[4fr_8fr]">
|
||||||
|
<Reveal>
|
||||||
|
<h3 className="text-[22px] font-medium tracking-tight">
|
||||||
|
One board state. Four skins.
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2.5 max-w-[42ch] text-[14.5px] leading-relaxed text-dim">
|
||||||
|
Bento, Cards, Index, and Console renderers share the same drag-drop,
|
||||||
|
popovers, and state. Switching the look is a setting, not a migration.
|
||||||
|
Try it - this demo is live.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<RendererDemo />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- monitors ---------- */}
|
||||||
|
<section id="monitors" className="scroll-mt-24 pt-24">
|
||||||
|
<SectionHead label="Monitors" tag="checks on a schedule" />
|
||||||
|
<Feature title="Uptime that watches itself" mock={<MonitorMock />}>
|
||||||
|
HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on an
|
||||||
|
in-process scheduler. Heartbeat history, latency graphs, and alert
|
||||||
|
rules per monitor - down means you hear about it, not discover it.
|
||||||
|
</Feature>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- domains ---------- */}
|
||||||
|
<section className="pt-24">
|
||||||
|
<SectionHead label="Domains" tag="rdap + whois + tls" />
|
||||||
|
<Feature
|
||||||
|
flip
|
||||||
|
title="Every domain, accounted for"
|
||||||
|
mock={<DomainMock />}
|
||||||
|
>
|
||||||
|
Expiry countdowns, TLS certificate chains, DNS records attributed to
|
||||||
|
their provider, and CT-log subdomain discovery. Field-level history on
|
||||||
|
every change.
|
||||||
|
</Feature>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- systems ---------- */}
|
||||||
|
<section className="pt-24">
|
||||||
|
<SectionHead label="Systems" tag="push-based agent" />
|
||||||
|
<Feature title="Hosts that report in" mock={<SystemMock />}>
|
||||||
|
A tiny <code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">dash-agent</code>{" "}
|
||||||
|
pushes CPU, memory, disk, network, temperatures, and per-container
|
||||||
|
stats - SMART, ZFS, and GPU when the tooling exists. One binary per
|
||||||
|
host, nothing else.
|
||||||
|
</Feature>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- status + alerts ---------- */}
|
||||||
|
<section className="pt-24">
|
||||||
|
<SectionHead label="Status & alerts" tag="public pages, real transports" />
|
||||||
|
<Feature
|
||||||
|
flip
|
||||||
|
title="Tell users before they ask"
|
||||||
|
mock={<StatusBand />}
|
||||||
|
>
|
||||||
|
Automatic incidents on monitor down, maintenance windows, public
|
||||||
|
status pages, and SVG badges. Alerts dispatch through webhook, Slack,
|
||||||
|
Discord, Telegram, Gotify, ntfy, or SMTP.
|
||||||
|
</Feature>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- stack ---------- */}
|
||||||
|
<section id="stack" className="scroll-mt-24 pt-24">
|
||||||
|
<SectionHead label="Stack" tag="boring, on purpose" />
|
||||||
|
<Reveal>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
"go 1.24",
|
||||||
|
"gin",
|
||||||
|
"sqlite (modernc)",
|
||||||
|
"embed.FS",
|
||||||
|
"openapi contract",
|
||||||
|
"react 18",
|
||||||
|
"docker · ~34 MB",
|
||||||
|
"goreleaser binaries",
|
||||||
|
"prometheus /api/metrics",
|
||||||
|
"pwa",
|
||||||
|
].map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded-md border border-line bg-surface px-3 py-1.5 font-mono text-[12px] text-dim"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.08}>
|
||||||
|
<p className="mt-5 max-w-[62ch] text-[14.5px] leading-relaxed text-dim">
|
||||||
|
It needs a persistent scheduler, WHOIS on port 43, ICMP, and SMTP
|
||||||
|
egress - none of which exist on serverless. So the deploy story is{" "}
|
||||||
|
<code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">docker run</code>
|
||||||
|
, and this page is the only part of Dash hosted here.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- install ---------- */}
|
||||||
|
<section id="install" className="scroll-mt-24 pt-24">
|
||||||
|
<SectionHead label="Install" tag="~1 min" />
|
||||||
|
<Install />
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<p className="mt-5 max-w-[62ch] text-[14.5px] leading-relaxed text-dim">
|
||||||
|
Everything is configured from the UI at{" "}
|
||||||
|
<code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">localhost:3000</code>
|
||||||
|
. All state lives in one directory. Importers for Homepage, Homarr,
|
||||||
|
and Dashy configs are built in.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- bottom cta ---------- */}
|
||||||
|
<Reveal className="mt-24">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-6 rounded-2xl border border-line bg-gradient-to-b from-surface to-bg p-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[26px] font-medium tracking-tight">Run it in one command.</h2>
|
||||||
|
<p className="mt-2 text-[14.5px] text-dim">MIT licensed. Self-hosted. Yours.</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Dvorinka/Dash"
|
||||||
|
className="flex h-11 items-center rounded-lg bg-text px-5 text-[14px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
Star on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mt-24 border-t border-line">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-5 px-5 py-8 text-[13px] text-faint">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<img src="/dash.svg" alt="" width={18} height={18} className="rounded-[4px]" />
|
||||||
|
Dash
|
||||||
|
</span>
|
||||||
|
<span>© 2026 · MIT License</span>
|
||||||
|
<div className="ml-auto flex gap-5">
|
||||||
|
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash">GitHub</a>
|
||||||
|
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash/tree/main/docs">Docs</a>
|
||||||
|
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash/releases">Releases</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
/* A live miniature of the product - rendered, not screenshotted. */
|
||||||
|
|
||||||
|
type Service = {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
letter: string;
|
||||||
|
tint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEDIA: Service[] = [
|
||||||
|
{ name: "Plex", host: "plex.localhost:32400", letter: "P", tint: "#e5a00d" },
|
||||||
|
{ name: "Jellyfin", host: "jellyfin.localhost:8096", letter: "J", tint: "#aa5cc3" },
|
||||||
|
{ name: "Immich", host: "immich.localhost:2283", letter: "I", tint: "#f16d9a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INFRA: Service[] = [
|
||||||
|
{ name: "Proxmox", host: "proxmox.localhost:8006", letter: "P", tint: "#e57000" },
|
||||||
|
{ name: "Pi-hole", host: "pihole.localhost", letter: "P", tint: "#f05454" },
|
||||||
|
{ name: "Gitea", host: "gitea.localhost:3000", letter: "G", tint: "#609926" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function useReduced() {
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
setReduced(mq.matches);
|
||||||
|
const fn = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||||
|
mq.addEventListener("change", fn);
|
||||||
|
return () => mq.removeEventListener("change", fn);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useClock() {
|
||||||
|
const [now, setNow] = useState<Date | null>(null);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [reduced]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWander(base: number, spread: number, ms: number) {
|
||||||
|
const [v, setV] = useState(base);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(
|
||||||
|
() => setV(Math.round(base + (Math.random() - 0.35) * spread)),
|
||||||
|
ms,
|
||||||
|
);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [base, spread, ms, reduced]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ down = false }: { down?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${down ? "bg-down" : "bg-up animate-pulse-dot"}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
i,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
i: number;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.45, delay: 0.05 * i, ease: [0.2, 0, 0, 1] }}
|
||||||
|
className={`rounded-lg border border-line bg-surface p-3 transition-colors duration-150 hover:border-line-strong hover:bg-surface-hover ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceTile({ s, i, down }: { s: Service; i: number; down?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tile i={i} className="col-span-3 flex items-center gap-3 sm:col-span-2">
|
||||||
|
<span
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-iconbg text-sm font-semibold"
|
||||||
|
style={{ color: s.tint }}
|
||||||
|
>
|
||||||
|
{s.letter}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-[13px] font-medium">{s.name}</span>
|
||||||
|
<span className="block truncate font-mono text-[10.5px] text-faint">{s.host}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-dim">
|
||||||
|
<StatusDot down={down} />
|
||||||
|
{down ? "degraded" : "up"}
|
||||||
|
</span>
|
||||||
|
</Tile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClockTile({ i }: { i: number }) {
|
||||||
|
const now = useClock();
|
||||||
|
return (
|
||||||
|
<Tile i={i} className="col-span-3 sm:col-span-2">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">local time</div>
|
||||||
|
<div className="mt-1.5 font-mono text-xl font-medium tabular-nums">
|
||||||
|
{now ? now.toLocaleTimeString("en-GB") : "--:--:--"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-[10.5px] text-dim">Europe/Prague</div>
|
||||||
|
</Tile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PiholeTile({ i }: { i: number }) {
|
||||||
|
const blocked = useWander(24, 6, 2600);
|
||||||
|
return (
|
||||||
|
<Tile i={i} className="col-span-3 sm:col-span-2">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">pi-hole</div>
|
||||||
|
<div className="mt-1.5 text-xl font-medium tabular-nums">
|
||||||
|
{blocked.toFixed(1)}
|
||||||
|
<span className="text-sm text-dim">%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-[10.5px] text-dim">queries blocked</div>
|
||||||
|
</Tile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeartbeatTile({ i }: { i: number }) {
|
||||||
|
const [bars, setBars] = useState<number[]>(() => Array.from({ length: 26 }, () => 1));
|
||||||
|
const ping = useWander(42, 30, 1500);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
setBars((b) => [...b.slice(1), Math.random() > 0.06 ? 1 : 0]);
|
||||||
|
}, 1100);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [reduced]);
|
||||||
|
return (
|
||||||
|
<Tile i={i} className="col-span-3 sm:col-span-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">api</span>
|
||||||
|
<span className="font-mono text-[10.5px] tabular-nums text-dim">{ping} ms</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex h-7 items-end gap-[3px]">
|
||||||
|
{bars.map((v, k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className={`flex-1 rounded-[2px] ${v ? "bg-up" : "bg-down"} transition-opacity duration-300`}
|
||||||
|
style={{ height: `${v ? 60 + ((k * 37) % 40) : 100}%`, opacity: v ? 0.85 : 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemTile({ i }: { i: number }) {
|
||||||
|
const cpu = useWander(34, 26, 2200);
|
||||||
|
return (
|
||||||
|
<Tile i={i} className="col-span-3 sm:col-span-2">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">nas-01</div>
|
||||||
|
<div className="mt-1.5 flex items-baseline gap-1">
|
||||||
|
<span className="text-xl font-medium tabular-nums">{cpu}%</span>
|
||||||
|
<span className="font-mono text-[10.5px] text-dim">cpu</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded bg-iconbg">
|
||||||
|
<div className="h-full rounded bg-accent transition-all duration-700" style={{ width: `${Math.min(cpu, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded bg-iconbg">
|
||||||
|
<div className="h-full rounded bg-up/70 transition-all duration-700" style={{ width: "61%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainTile({ i }: { i: number }) {
|
||||||
|
return (
|
||||||
|
<Tile i={i} className="col-span-3 sm:col-span-2">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">tdvorak.dev</div>
|
||||||
|
<div className="mt-1.5 text-xl font-medium">
|
||||||
|
260<span className="text-sm text-dim">d</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-[10.5px] text-dim">until expiry · tls 81d</div>
|
||||||
|
</Tile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Board() {
|
||||||
|
const [warn, setWarn] = useState(-1);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
const pick = Math.floor(Math.random() * MEDIA.length);
|
||||||
|
setWarn(pick);
|
||||||
|
setTimeout(() => setWarn(-1), 2600);
|
||||||
|
}, 11000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [reduced]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-line-strong bg-bg shadow-[0_24px_80px_-24px_rgba(0,0,0,0.7)]">
|
||||||
|
{/* window chrome */}
|
||||||
|
<div className="flex h-9 items-center gap-2 border-b border-line bg-surface px-3.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
|
||||||
|
<span className="ml-2 rounded-md border border-line bg-bg px-2 py-0.5 font-mono text-[10px] text-faint">
|
||||||
|
localhost:3000
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto hidden items-center gap-1 rounded-md border border-line px-2 py-0.5 font-mono text-[10px] text-faint sm:flex">
|
||||||
|
⌘K
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 sm:p-5">
|
||||||
|
<div className="mb-2.5 flex items-center gap-3">
|
||||||
|
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-dim">
|
||||||
|
Media
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-faint">05</span>
|
||||||
|
<span className="h-px flex-1 bg-line" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-2.5">
|
||||||
|
{MEDIA.map((s, k) => (
|
||||||
|
<ServiceTile key={s.name} s={s} i={k} down={warn === k} />
|
||||||
|
))}
|
||||||
|
<ClockTile i={3} />
|
||||||
|
<PiholeTile i={4} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2.5 mt-5 flex items-center gap-3">
|
||||||
|
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-dim">
|
||||||
|
Infrastructure
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-faint">06</span>
|
||||||
|
<span className="h-px flex-1 bg-line" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-2.5">
|
||||||
|
{INFRA.map((s, k) => (
|
||||||
|
<ServiceTile key={s.name} s={s} i={k + 5} />
|
||||||
|
))}
|
||||||
|
<HeartbeatTile i={8} />
|
||||||
|
<SystemTile i={9} />
|
||||||
|
<DomainTile i={10} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function CopyButton({ text, label }: { text: string; label: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
} catch {
|
||||||
|
/* clipboard unavailable - select manually */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-up)" strokeWidth="2.5" aria-hidden="true">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
import { Reveal } from "./Reveal";
|
||||||
|
|
||||||
|
const CURL = "curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh";
|
||||||
|
const DOCKER = "docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest";
|
||||||
|
|
||||||
|
export function Install() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2.5 md:grid-cols-2">
|
||||||
|
<Reveal className="overflow-hidden rounded-xl border border-line bg-surface/50">
|
||||||
|
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
|
||||||
|
one-liner
|
||||||
|
</span>
|
||||||
|
<CopyButton text={CURL} label="Copy install command" />
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-4 font-mono text-[12.5px] leading-7 text-dim">
|
||||||
|
<span className="text-text">$</span> curl -fsSL https://raw.githubusercontent.com/
|
||||||
|
{"\n"} Dvorinka/Dash/main/install.sh | sh{"\n"}
|
||||||
|
<span className="text-faint"># installs into ./dash, serves :3000</span>
|
||||||
|
</pre>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.08} className="overflow-hidden rounded-xl border border-line bg-surface/50">
|
||||||
|
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
|
||||||
|
docker
|
||||||
|
</span>
|
||||||
|
<CopyButton text={DOCKER} label="Copy docker command" />
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-4 font-mono text-[12.5px] leading-7 text-dim">
|
||||||
|
<span className="text-text">$</span> docker run -d -v ./data:/data \{"\n"}
|
||||||
|
{" "}-p 3000:3000 \{"\n"}
|
||||||
|
{" "}ghcr.io/dvorinka/dash:latest
|
||||||
|
</pre>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/* Small live mocks for feature rows - data-shaped divs, no images. */
|
||||||
|
|
||||||
|
function useReduced() {
|
||||||
|
const [r, setR] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
setR(mq.matches);
|
||||||
|
const fn = (e: MediaQueryListEvent) => setR(e.matches);
|
||||||
|
mq.addEventListener("change", fn);
|
||||||
|
return () => mq.removeEventListener("change", fn);
|
||||||
|
}, []);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePing(base: number, spread: number, ms: number) {
|
||||||
|
const [v, setV] = useState(base);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(() => setV(Math.round(base + (Math.random() - 0.4) * spread)), ms);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [base, spread, ms, reduced]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeartbeatStrip({ seed, ms }: { seed: number; ms: number }) {
|
||||||
|
const [bars, setBars] = useState<number[]>(() =>
|
||||||
|
Array.from({ length: 32 }, (_, k) => ((k * seed) % 23 === 0 ? 0 : 1)),
|
||||||
|
);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(
|
||||||
|
() => setBars((b) => [...b.slice(1), Math.random() > 0.045 ? 1 : 0]),
|
||||||
|
ms,
|
||||||
|
);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [seed, ms, reduced]);
|
||||||
|
return (
|
||||||
|
<div className="flex h-6 flex-1 items-end gap-[2px]">
|
||||||
|
{bars.map((v, k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className={`flex-1 rounded-[1.5px] transition-colors duration-300 ${v ? "bg-up" : "bg-down"}`}
|
||||||
|
style={{ height: v ? `${55 + ((k * 29) % 45)}%` : "100%", opacity: v ? 0.8 : 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONITORS = [
|
||||||
|
{ name: "gitea.tdvorak.dev", kind: "https · 30s", up: "99.98", base: 38, seed: 3 },
|
||||||
|
{ name: "api.internal", kind: "tcp · 60s", up: "100.00", base: 12, seed: 7 },
|
||||||
|
{ name: "pihole.local", kind: "keyword · 60s", up: "99.62", base: 24, seed: 11 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function MonitorRow({ m, i }: { m: (typeof MONITORS)[number]; i: number }) {
|
||||||
|
const ping = usePing(m.base, 18, 1700 + i * 300);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 px-4 py-3">
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<div className="truncate font-mono text-[12px]">{m.name}</div>
|
||||||
|
<div className="font-mono text-[10px] text-faint">{m.kind}</div>
|
||||||
|
</div>
|
||||||
|
<HeartbeatStrip seed={m.seed} ms={1300 + i * 250} />
|
||||||
|
<div className="w-14 text-right font-mono text-[11px] tabular-nums text-dim">{ping} ms</div>
|
||||||
|
<div className="w-16 rounded border border-line px-1.5 py-0.5 text-center font-mono text-[10px] text-up">
|
||||||
|
{m.up}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitorMock() {
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-line rounded-xl border border-line bg-surface/50">
|
||||||
|
{MONITORS.map((m, i) => (
|
||||||
|
<MonitorRow key={m.name} m={m} i={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOMAINS = [
|
||||||
|
{ name: "tdvorak.dev", days: 260, pct: 71, tags: ["porkbun", "cloudflare", "tls 81d"] },
|
||||||
|
{ name: "kan.tdvorak.dev", days: 260, pct: 71, tags: ["porkbun", "cloudflare", "tls 81d"] },
|
||||||
|
{ name: "old-legacy.net", days: 12, pct: 8, tags: ["godaddy", "ns1", "tls 9d"], warn: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DomainMock() {
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-line rounded-xl border border-line bg-surface/50">
|
||||||
|
{DOMAINS.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center gap-4 px-4 py-3.5">
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<div className="truncate font-mono text-[12px]">{d.name}</div>
|
||||||
|
<div className="mt-1.5 h-1 w-full overflow-hidden rounded bg-iconbg">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded ${d.warn ? "bg-down" : "bg-accent"}`}
|
||||||
|
style={{ width: `${d.pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[12px] tabular-nums ${d.warn ? "text-down" : "text-dim"}`}
|
||||||
|
>
|
||||||
|
{d.days}d
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto hidden gap-1.5 sm:flex">
|
||||||
|
{d.tags.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-faint"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemMock() {
|
||||||
|
const cpu = usePing(37, 30, 1900);
|
||||||
|
const mem = usePing(61, 6, 2600);
|
||||||
|
const R = 44;
|
||||||
|
const C = 2 * Math.PI * R;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-6 rounded-xl border border-line bg-surface/50 p-5">
|
||||||
|
<div className="relative h-28 w-28 shrink-0">
|
||||||
|
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
||||||
|
<circle cx="50" cy="50" r={R} fill="none" stroke="var(--color-iconbg)" strokeWidth="7" />
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={R}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth="7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={C}
|
||||||
|
strokeDashoffset={C - (C * Math.min(cpu, 100)) / 100}
|
||||||
|
style={{ transition: "stroke-dashoffset 0.9s cubic-bezier(0.2,0,0,1)" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-xl font-medium tabular-nums">{cpu}%</span>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-faint">cpu</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between font-mono text-[10px] text-faint">
|
||||||
|
<span>memory</span>
|
||||||
|
<span className="tabular-nums">{mem}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded bg-iconbg">
|
||||||
|
<div className="h-full rounded bg-up/80 transition-all duration-700" style={{ width: `${mem}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between font-mono text-[10px] text-faint">
|
||||||
|
<span>disk /data</span>
|
||||||
|
<span>44%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded bg-iconbg">
|
||||||
|
<div className="h-full rounded bg-accent/80" style={{ width: "44%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between font-mono text-[10px] text-faint">
|
||||||
|
<span>8 containers</span>
|
||||||
|
<span className="tabular-nums">load 0.42 · up 14d</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERTS: { at: string; text: string; kind: "ok" | "down" | "warn" }[] = [
|
||||||
|
{ at: "12:04:11", text: "pihole.local recovered", kind: "ok" },
|
||||||
|
{ at: "12:03:48", text: "pihole.local is down", kind: "down" },
|
||||||
|
{ at: "11:58:02", text: "old-legacy.net expires in 12d", kind: "warn" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALERT_STYLE: Record<(typeof ALERTS)[number]["kind"], string> = {
|
||||||
|
ok: "text-up",
|
||||||
|
down: "text-destructive",
|
||||||
|
warn: "text-down",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBand() {
|
||||||
|
const [i, setI] = useState(0);
|
||||||
|
const reduced = useReduced();
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
const t = setInterval(() => setI((v) => (v + 1) % ALERTS.length), 3000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [reduced]);
|
||||||
|
const a = ALERTS[i];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-surface/50 p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
|
||||||
|
status page · public
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-faint">
|
||||||
|
/status/lab
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-md bg-up/90 px-3 py-2 text-center text-[12.5px] font-medium text-[#052e16]">
|
||||||
|
All systems operational
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{["edge", "api", "media"].map((s) => (
|
||||||
|
<div key={s} className="flex items-center gap-2.5 font-mono text-[11px] text-dim">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-up animate-pulse-dot" />
|
||||||
|
{s}
|
||||||
|
<span className="ml-auto text-faint">operational</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 border-t border-line pt-3.5">
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[10.5px]">
|
||||||
|
<span className="text-faint">{a.at}</span>
|
||||||
|
<span className={`truncate ${ALERT_STYLE[a.kind]}`}>{a.text}</span>
|
||||||
|
<span className="ml-auto shrink-0 rounded border border-line px-1.5 py-0.5 text-[9.5px] text-faint">
|
||||||
|
→ slack · ntfy · smtp
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export function Nav() {
|
||||||
|
return (
|
||||||
|
<nav className="sticky top-0 z-20 border-b border-line bg-bg/80 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex h-14 max-w-5xl items-center gap-2.5 px-5">
|
||||||
|
<a href="#" className="flex items-center gap-2 text-[14.5px] font-semibold tracking-tight">
|
||||||
|
<img src="/dash.svg" alt="" width={20} height={20} className="rounded-[5px]" />
|
||||||
|
Dash
|
||||||
|
</a>
|
||||||
|
<div className="ml-4 hidden gap-0.5 sm:flex">
|
||||||
|
{[
|
||||||
|
["Boards", "#renderers"],
|
||||||
|
["Monitors", "#monitors"],
|
||||||
|
["Stack", "#stack"],
|
||||||
|
["Install", "#install"],
|
||||||
|
].map(([label, href]) => (
|
||||||
|
<a
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className="rounded-md px-2.5 py-1.5 text-[13px] text-dim transition-colors hover:bg-surface-hover hover:text-text"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="https://github.com/Dvorinka/Dash/tree/main/docs"
|
||||||
|
className="rounded-md px-2.5 py-1.5 text-[13px] text-dim transition-colors hover:bg-surface-hover hover:text-text"
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="https://github.com/Dvorinka/Dash"
|
||||||
|
aria-label="GitHub repository"
|
||||||
|
className="flex h-[34px] w-[34px] items-center justify-center rounded-lg border border-line bg-surface text-dim transition-colors hover:border-line-strong hover:text-text"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#install"
|
||||||
|
className="flex h-[34px] items-center rounded-lg bg-text px-3.5 text-[13px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
/* Same state, four skins - the product's renderer seam, playable in-page. */
|
||||||
|
|
||||||
|
type Mode = "bento" | "cards" | "index" | "console";
|
||||||
|
const MODES: Mode[] = ["bento", "cards", "index", "console"];
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ name: "Plex", host: "plex.local", letter: "P", tint: "#e5a00d" },
|
||||||
|
{ name: "Jellyfin", host: "jf.local", letter: "J", tint: "#aa5cc3" },
|
||||||
|
{ name: "Pi-hole", host: "pihole.local", letter: "P", tint: "#f05454" },
|
||||||
|
{ name: "Gitea", host: "git.local", letter: "G", tint: "#609926" },
|
||||||
|
{ name: "Grafana", host: "graf.local", letter: "G", tint: "#f46800" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BENTO_SPAN = ["sm:col-span-2", "sm:col-span-2", "sm:col-span-2", "sm:col-span-3", "sm:col-span-3"];
|
||||||
|
|
||||||
|
function Chip({ up = true }: { up?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-dim">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-up animate-pulse-dot" : "bg-down"}`} />
|
||||||
|
up
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RendererDemo() {
|
||||||
|
const [mode, setMode] = useState<Mode>("bento");
|
||||||
|
const locked = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (!locked.current) {
|
||||||
|
setMode((m) => MODES[(MODES.indexOf(m) + 1) % MODES.length]);
|
||||||
|
}
|
||||||
|
}, 3400);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pick = (m: Mode) => {
|
||||||
|
locked.current = true;
|
||||||
|
setMode(m);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridCls =
|
||||||
|
mode === "bento"
|
||||||
|
? "grid grid-cols-2 gap-2 sm:grid-cols-6"
|
||||||
|
: mode === "cards"
|
||||||
|
? "grid grid-cols-2 gap-2 sm:grid-cols-5"
|
||||||
|
: mode === "console"
|
||||||
|
? "grid grid-cols-1 gap-2 sm:grid-cols-3"
|
||||||
|
: "flex flex-col divide-y divide-line";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-bg">
|
||||||
|
<div className="flex items-center gap-2 border-b border-line px-4 py-3">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
|
||||||
|
same board, four renderers
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex gap-1">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={mode === m}
|
||||||
|
onClick={() => pick(m)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors duration-150 ${
|
||||||
|
mode === m
|
||||||
|
? "border-line-strong bg-surface-hover text-text"
|
||||||
|
: "border-transparent text-faint hover:text-dim"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className={gridCls}>
|
||||||
|
{ITEMS.map((it, k) => (
|
||||||
|
<motion.div
|
||||||
|
key={it.name}
|
||||||
|
layout
|
||||||
|
transition={{ duration: 0.5, ease: [0.2, 0, 0, 1] }}
|
||||||
|
className={mode === "index" ? "" : mode === "bento" ? `col-span-1 ${BENTO_SPAN[k]}` : "col-span-1"}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key={mode}
|
||||||
|
initial={{ opacity: 0.4 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className={
|
||||||
|
mode === "index"
|
||||||
|
? "flex items-center gap-4 py-3"
|
||||||
|
: mode === "console"
|
||||||
|
? "flex items-center gap-3 rounded-[4px] border border-line px-3 py-2.5 font-mono"
|
||||||
|
: "flex items-center gap-3 rounded-lg border border-line bg-surface p-3"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mode === "index" ? (
|
||||||
|
<>
|
||||||
|
<span className="font-mono text-[11px] text-faint">0{k + 1}</span>
|
||||||
|
<span className="text-[14px] font-medium tracking-tight">{it.name}</span>
|
||||||
|
<span className="hidden font-mono text-[11px] text-faint sm:block">{it.host}</span>
|
||||||
|
<span className="ml-auto"><Chip /></span>
|
||||||
|
</>
|
||||||
|
) : mode === "console" ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[11px] text-text">{it.name.toLowerCase()}</span>
|
||||||
|
<span className="text-[10px] text-faint">{it.host}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-up">[ up ]</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`flex shrink-0 items-center justify-center rounded-md bg-iconbg font-semibold ${mode === "cards" ? "h-7 w-7 text-[11px]" : "h-9 w-9 text-sm"}`}
|
||||||
|
style={{ color: it.tint }}
|
||||||
|
>
|
||||||
|
{it.letter}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className={`block truncate font-medium ${mode === "cards" ? "text-[12px]" : "text-[13px]"}`}>
|
||||||
|
{it.name}
|
||||||
|
</span>
|
||||||
|
{mode === "bento" && (
|
||||||
|
<span className="block truncate font-mono text-[10.5px] text-faint">{it.host}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto"><Chip /></span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Reveal({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.55, delay, ease: [0.2, 0, 0, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
<!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 — Self-hosted homelab dashboard</title>
|
|
||||||
<meta name="description" content="Service boards, uptime monitors, domain watch, and system metrics in one container. A single static Go binary with an embedded React UI. No YAML, no external database.">
|
|
||||||
<link rel="canonical" href="https://dash.vercel.app">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:title" content="Dash — Self-hosted homelab dashboard">
|
|
||||||
<meta property="og:description" content="Service boards, uptime monitors, domain watch, and system metrics in one container. One static Go binary, no YAML.">
|
|
||||||
<meta property="og:image" content="https://dash.vercel.app/assets/board.png">
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<link rel="icon" href="assets/dash.svg" type="image/svg+xml">
|
|
||||||
<style>
|
|
||||||
@font-face {
|
|
||||||
font-family: "Geist";
|
|
||||||
src: url("fonts/geist-var.woff2") format("woff2");
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Geist Mono";
|
|
||||||
src: url("fonts/geist-mono-var.woff2") format("woff2");
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; }
|
|
||||||
html { -webkit-font-smoothing: antialiased; scroll-behavior: smooth; }
|
|
||||||
body {
|
|
||||||
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
.mono { font-family: "Geist Mono", ui-monospace, monospace; }
|
|
||||||
a { color: inherit; text-decoration: none; }
|
|
||||||
img { display: block; max-width: 100%; }
|
|
||||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
|
|
||||||
|
|
||||||
/* ---------- reveal ---------- */
|
|
||||||
.reveal { opacity: 0; transform: translateY(14px); transition: opacity 500ms var(--ease), transform 500ms var(--ease); }
|
|
||||||
.reveal.in { opacity: 1; transform: none; }
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
html { scroll-behavior: auto; }
|
|
||||||
.reveal { opacity: 1; transform: none; transition: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- nav ---------- */
|
|
||||||
nav {
|
|
||||||
position: sticky; top: 0; z-index: 10;
|
|
||||||
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
nav .wrap { display: flex; align-items: center; gap: 10px; height: 56px; }
|
|
||||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; }
|
|
||||||
.brand img { width: 22px; height: 22px; border-radius: 5px; }
|
|
||||||
.navlinks { display: flex; gap: 2px; margin-left: 18px; }
|
|
||||||
.navlinks a {
|
|
||||||
padding: 6px 10px; border-radius: 7px;
|
|
||||||
font-size: 13.5px; color: var(--text-dim);
|
|
||||||
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
||||||
}
|
|
||||||
.navlinks a:hover { color: var(--text); background: var(--surface-hover); }
|
|
||||||
.nav-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
|
||||||
height: 34px; padding: 0 14px; border-radius: 8px;
|
|
||||||
border: 1px solid var(--border); background: var(--surface); color: var(--text-dim);
|
|
||||||
font-family: inherit; font-size: 13.5px; font-weight: 500;
|
|
||||||
cursor: pointer; white-space: nowrap;
|
|
||||||
transition: color 150ms var(--ease), border-color 150ms var(--ease), background 150ms var(--ease), transform 100ms var(--ease);
|
|
||||||
}
|
|
||||||
.btn:hover { color: var(--text); border-color: var(--border-strong); }
|
|
||||||
.btn:active { transform: scale(0.97); }
|
|
||||||
.btn.primary { background: var(--text); color: var(--bg); border-color: transparent; }
|
|
||||||
.btn.primary:hover { background: #e4e4e7; color: var(--bg); }
|
|
||||||
.btn.icon { width: 34px; padding: 0; }
|
|
||||||
|
|
||||||
/* ---------- section header (product convention) ---------- */
|
|
||||||
.sec-head {
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
|
||||||
padding: 4px 2px; margin-bottom: 26px;
|
|
||||||
}
|
|
||||||
.sec-head h2 {
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace;
|
|
||||||
font-size: 11px; font-weight: 600;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.14em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.sec-head .line { flex: 1; height: 1px; background: var(--border); }
|
|
||||||
.sec-head .tag { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; color: var(--text-faint); }
|
|
||||||
|
|
||||||
/* ---------- hero ---------- */
|
|
||||||
.hero { padding: 92px 0 0; }
|
|
||||||
.eyebrow {
|
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace;
|
|
||||||
font-size: 12px; color: var(--text-dim);
|
|
||||||
padding: 5px 10px; border: 1px solid var(--border); border-radius: 999px;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--up); }
|
|
||||||
.hero h1 {
|
|
||||||
margin-top: 22px;
|
|
||||||
font-size: clamp(40px, 6.4vw, 68px);
|
|
||||||
font-weight: 560;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
line-height: 1.04;
|
|
||||||
max-width: 15ch;
|
|
||||||
}
|
|
||||||
.hero p.sub {
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 17px; line-height: 1.6;
|
|
||||||
color: var(--text-dim);
|
|
||||||
max-width: 56ch;
|
|
||||||
}
|
|
||||||
.hero .cta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 30px; }
|
|
||||||
|
|
||||||
.cmdline {
|
|
||||||
display: inline-flex; align-items: center; gap: 10px;
|
|
||||||
height: 38px; padding: 0 8px 0 14px;
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 13px; color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.cmdline code { color: var(--text); }
|
|
||||||
.copybtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px; border: none; border-radius: 6px;
|
|
||||||
background: transparent; color: var(--text-faint); cursor: pointer;
|
|
||||||
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
||||||
}
|
|
||||||
.copybtn:hover { color: var(--text); background: var(--surface-hover); }
|
|
||||||
|
|
||||||
/* ---------- framed screenshot ---------- */
|
|
||||||
.frame {
|
|
||||||
margin-top: 56px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 24px 80px -24px rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
.frame-bar {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
height: 36px; padding: 0 14px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.frame-bar i { width: 9px; height: 9px; border-radius: 50%; background: var(--border-strong); }
|
|
||||||
.frame-bar .url {
|
|
||||||
margin-left: 10px;
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; color: var(--text-faint);
|
|
||||||
padding: 3px 10px; border: 1px solid var(--border); border-radius: 6px;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- stat strip ---------- */
|
|
||||||
.stats {
|
|
||||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
|
||||||
border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
margin-top: 72px; overflow: hidden;
|
|
||||||
}
|
|
||||||
.stats div { padding: 18px 20px; border-left: 1px solid var(--border); }
|
|
||||||
.stats div:first-child { border-left: none; }
|
|
||||||
.stats .k { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-faint); }
|
|
||||||
.stats .v { margin-top: 7px; font-size: 15px; font-weight: 550; }
|
|
||||||
|
|
||||||
/* ---------- features bento ---------- */
|
|
||||||
section { padding: 96px 0 0; }
|
|
||||||
.bento { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
|
|
||||||
.card {
|
|
||||||
grid-column: span 3;
|
|
||||||
padding: 26px 26px 24px;
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
transition: border-color 150ms var(--ease), background 150ms var(--ease);
|
|
||||||
}
|
|
||||||
.card:hover { border-color: var(--border-strong); background: var(--surface-hover); }
|
|
||||||
.card.wide { grid-column: span 6; }
|
|
||||||
.card.third { grid-column: span 2; }
|
|
||||||
.card .label {
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.14em; color: var(--accent);
|
|
||||||
}
|
|
||||||
.card h3 { margin-top: 10px; font-size: 17px; font-weight: 560; letter-spacing: -0.01em; }
|
|
||||||
.card p { margin-top: 8px; font-size: 14px; line-height: 1.6; color: var(--text-dim); }
|
|
||||||
.card .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 16px; }
|
|
||||||
.card .chips span {
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; color: var(--text-dim);
|
|
||||||
padding: 4px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg);
|
|
||||||
}
|
|
||||||
.beats { display: flex; align-items: flex-end; gap: 3px; height: 30px; margin-top: 18px; }
|
|
||||||
.beats i { flex: 1; border-radius: 2px; background: var(--up); opacity: 0.85; }
|
|
||||||
.beats i.warn { background: var(--accent); }
|
|
||||||
|
|
||||||
/* ---------- screenshots ---------- */
|
|
||||||
.shots { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
|
||||||
.shot {
|
|
||||||
border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
|
|
||||||
background: var(--surface);
|
|
||||||
transition: border-color 150ms var(--ease);
|
|
||||||
}
|
|
||||||
.shot:hover { border-color: var(--border-strong); }
|
|
||||||
.shot img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; object-position: top; }
|
|
||||||
.shot figcaption {
|
|
||||||
padding: 10px 14px; border-top: 1px solid var(--border);
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- install ---------- */
|
|
||||||
.install-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
||||||
.term {
|
|
||||||
border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.term-head {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.term pre {
|
|
||||||
padding: 18px; overflow-x: auto;
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.8;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.term pre b { color: var(--text); font-weight: 500; }
|
|
||||||
.term pre .c { color: var(--text-faint); }
|
|
||||||
.install-note { margin-top: 18px; font-size: 14px; color: var(--text-dim); line-height: 1.6; max-width: 62ch; }
|
|
||||||
.install-note code {
|
|
||||||
font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px;
|
|
||||||
padding: 2px 6px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- bottom cta ---------- */
|
|
||||||
.cta-band {
|
|
||||||
margin-top: 96px;
|
|
||||||
border: 1px solid var(--border); border-radius: 14px;
|
|
||||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 130%);
|
|
||||||
padding: 44px 40px;
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.cta-band h2 { font-size: 26px; font-weight: 560; letter-spacing: -0.02em; }
|
|
||||||
.cta-band p { margin-top: 8px; font-size: 14.5px; color: var(--text-dim); }
|
|
||||||
|
|
||||||
/* ---------- footer ---------- */
|
|
||||||
footer { margin-top: 96px; border-top: 1px solid var(--border); }
|
|
||||||
footer .wrap {
|
|
||||||
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
|
|
||||||
padding-top: 26px; padding-bottom: 40px;
|
|
||||||
font-size: 13px; color: var(--text-faint);
|
|
||||||
}
|
|
||||||
footer .flinks { display: flex; gap: 18px; margin-left: auto; }
|
|
||||||
footer a { color: var(--text-dim); transition: color 150ms var(--ease); }
|
|
||||||
footer a:hover { color: var(--text); }
|
|
||||||
|
|
||||||
/* ---------- responsive ---------- */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.navlinks { display: none; }
|
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.stats div:nth-child(3) { border-left: none; }
|
|
||||||
.stats div:nth-child(n+3) { border-top: 1px solid var(--border); }
|
|
||||||
.card, .card.third { grid-column: span 6; }
|
|
||||||
.shots { grid-template-columns: 1fr; }
|
|
||||||
.install-grid { grid-template-columns: 1fr; }
|
|
||||||
.hero { padding-top: 64px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<div class="wrap">
|
|
||||||
<a class="brand" href="#"><img src="assets/dash.svg" alt="Dash logo">Dash</a>
|
|
||||||
<div class="navlinks">
|
|
||||||
<a href="#features">Features</a>
|
|
||||||
<a href="#screenshots">Screenshots</a>
|
|
||||||
<a href="#install">Install</a>
|
|
||||||
<a href="https://github.com/Dvorinka/Dash/tree/main/docs">Docs</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-right">
|
|
||||||
<a class="btn icon" href="https://github.com/Dvorinka/Dash" aria-label="GitHub repository">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
|
||||||
</a>
|
|
||||||
<a class="btn primary" href="#install">Get started</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header class="hero">
|
|
||||||
<div class="wrap">
|
|
||||||
<span class="eyebrow reveal"><span class="dot"></span>self-hosted · open source · MIT</span>
|
|
||||||
<h1 class="reveal">One container for your whole homelab.</h1>
|
|
||||||
<p class="sub reveal">Dash is a self-hosted dashboard: service boards, uptime monitors, domain
|
|
||||||
watch, and system metrics in a single static Go binary with the UI baked in.
|
|
||||||
No YAML, no external database, no mandatory accounts.</p>
|
|
||||||
<div class="cta reveal">
|
|
||||||
<a class="btn primary" href="#install">Get started</a>
|
|
||||||
<a class="btn" href="https://github.com/Dvorinka/Dash">GitHub</a>
|
|
||||||
<span class="cmdline"><code>docker run -p 3000:3000 ghcr.io/dvorinka/dash</code>
|
|
||||||
<button class="copybtn" data-copy="docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest" aria-label="Copy docker command">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="frame reveal">
|
|
||||||
<div class="frame-bar"><i></i><i></i><i></i><span class="url">dash.localhost:3000</span></div>
|
|
||||||
<img src="assets/board.png" alt="Dash service board with sections for media, infrastructure, and apps" width="1440" height="900">
|
|
||||||
</div>
|
|
||||||
<div class="stats reveal">
|
|
||||||
<div><div class="k">Deploy</div><div class="v">One static binary</div></div>
|
|
||||||
<div><div class="k">Database</div><div class="v">SQLite, embedded</div></div>
|
|
||||||
<div><div class="k">Config</div><div class="v">UI-managed, no YAML</div></div>
|
|
||||||
<div><div class="k">License</div><div class="v">MIT</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="features">
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="sec-head"><h2>Features</h2><span class="line"></span><span class="tag">06</span></div>
|
|
||||||
<div class="bento">
|
|
||||||
<div class="card reveal">
|
|
||||||
<span class="label">Boards</span>
|
|
||||||
<h3>Service boards your way</h3>
|
|
||||||
<p>Sections, drag-and-drop, multi-URL services, icon upload. Four renderers
|
|
||||||
- Bento, Cards, Index, Console - share one state, so the look is a setting,
|
|
||||||
not a migration.</p>
|
|
||||||
<div class="chips"><span>bento</span><span>cards</span><span>index</span><span>console</span><span>dnd</span><span>cmd+k</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="card reveal">
|
|
||||||
<span class="label">Monitors</span>
|
|
||||||
<h3>Uptime checks on a schedule</h3>
|
|
||||||
<p>HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks with heartbeat
|
|
||||||
history, latency graphs, and per-monitor alert rules.</p>
|
|
||||||
<div class="beats" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i class="warn"></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
<div class="card third reveal">
|
|
||||||
<span class="label">Domains</span>
|
|
||||||
<h3>Domain watch</h3>
|
|
||||||
<p>RDAP/WHOIS, TLS expiry, DNS records attributed to their provider, and
|
|
||||||
CT-log subdomain discovery.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card third reveal">
|
|
||||||
<span class="label">Systems</span>
|
|
||||||
<h3>System metrics</h3>
|
|
||||||
<p>A tiny <span class="mono">dash-agent</span> pushes CPU, memory, disk,
|
|
||||||
network, temps, and per-container stats.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card third reveal">
|
|
||||||
<span class="label">Status</span>
|
|
||||||
<h3>Status pages</h3>
|
|
||||||
<p>Public <span class="mono">/status/:slug</span> pages, incidents,
|
|
||||||
maintenance windows, and SVG badges.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card wide reveal">
|
|
||||||
<span class="label">Alerts</span>
|
|
||||||
<h3>Notified where you already are</h3>
|
|
||||||
<p>Alert rules for monitors, domains, and systems dispatch through the
|
|
||||||
transports you already run.</p>
|
|
||||||
<div class="chips"><span>webhook</span><span>slack</span><span>discord</span><span>telegram</span><span>gotify</span><span>ntfy</span><span>smtp</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="screenshots">
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="sec-head"><h2>Screenshots</h2><span class="line"></span><span class="tag">03</span></div>
|
|
||||||
<div class="shots">
|
|
||||||
<figure class="shot reveal"><img src="assets/monitors.png" alt="Uptime monitors list with status, uptime percentage, and latency" width="1440" height="900" loading="lazy" decoding="async"><figcaption>uptime monitors</figcaption></figure>
|
|
||||||
<figure class="shot reveal"><img src="assets/domains.png" alt="Domain watch page with expiry countdowns and provider detection" width="1440" height="900" loading="lazy" decoding="async"><figcaption>domain watch</figcaption></figure>
|
|
||||||
<figure class="shot reveal"><img src="assets/status-page.png" alt="Public status page showing operational state of services" width="1440" height="900" loading="lazy" decoding="async"><figcaption>public status page</figcaption></figure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="install">
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="sec-head"><h2>Install</h2><span class="line"></span><span class="tag">~1 min</span></div>
|
|
||||||
<div class="install-grid">
|
|
||||||
<div class="term reveal">
|
|
||||||
<div class="term-head"><span>one-liner</span>
|
|
||||||
<button class="copybtn" data-copy="curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh" aria-label="Copy install command">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre><b>$</b> curl -fsSL https://raw.githubusercontent.com/<br> Dvorinka/Dash/main/install.sh | sh<br><span class="c"># installs into ./dash, serves :3000</span></pre>
|
|
||||||
</div>
|
|
||||||
<div class="term reveal">
|
|
||||||
<div class="term-head"><span>docker</span>
|
|
||||||
<button class="copybtn" data-copy="docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest" aria-label="Copy docker command">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre><b>$</b> docker run -d -v ./data:/data \<br> -p 3000:3000 \<br> ghcr.io/dvorinka/dash:latest</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="install-note reveal">Everything is configured from the UI at
|
|
||||||
<code>http://localhost:3000</code>. All state lives in one directory - a SQLite
|
|
||||||
database plus uploaded icons. Importers for Homepage, Homarr, and Dashy
|
|
||||||
configs are built in.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="cta-band reveal">
|
|
||||||
<div>
|
|
||||||
<h2>Run it in one command.</h2>
|
|
||||||
<p>MIT licensed. Self-hosted. Yours.</p>
|
|
||||||
</div>
|
|
||||||
<a class="btn primary" href="https://github.com/Dvorinka/Dash">Star on GitHub</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<div class="wrap">
|
|
||||||
<a class="brand" href="#"><img src="assets/dash.svg" alt="" width="18" height="18" style="border-radius:4px">Dash</a>
|
|
||||||
<span>© 2026 · MIT License</span>
|
|
||||||
<div class="flinks">
|
|
||||||
<a href="https://github.com/Dvorinka/Dash">GitHub</a>
|
|
||||||
<a href="https://github.com/Dvorinka/Dash/tree/main/docs">Docs</a>
|
|
||||||
<a href="https://github.com/Dvorinka/Dash/releases">Releases</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// copy buttons
|
|
||||||
for (const b of document.querySelectorAll(".copybtn")) {
|
|
||||||
b.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(b.dataset.copy);
|
|
||||||
b.style.color = "var(--up)";
|
|
||||||
setTimeout(() => { b.style.color = ""; }, 1200);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// reveal on scroll
|
|
||||||
const io = new IntersectionObserver((es) => {
|
|
||||||
for (const e of es) if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); }
|
|
||||||
}, { threshold: 0.12 });
|
|
||||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// repo has a root package-lock.json too - keep Next's tracing inside landing/
|
||||||
|
outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+1834
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "dash-landing",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"motion": "^13.4.4",
|
||||||
|
"next": "^15.5.26",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
|
||||||
|
<g fill="#3b82f6">
|
||||||
|
<rect x="120" y="120" width="124" height="124" rx="28"/>
|
||||||
|
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
|
||||||
|
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
|
||||||
|
<rect x="268" y="268" width="124" height="124" rx="28"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
".claude/**",
|
".claude/**",
|
||||||
"tools/oxlint/anti-slop/**",
|
"tools/oxlint/anti-slop/**",
|
||||||
"packages/api-client/src/schema.d.ts",
|
"packages/api-client/src/schema.d.ts",
|
||||||
|
"landing/next-env.d.ts",
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user