docs: updates homepage
This commit is contained in:
@@ -0,0 +1,868 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
// The app's own logo, not a copy, so the hero can never drift from the product.
|
||||
import logo from "../../../../assets/logo.svg";
|
||||
|
||||
// Entries are taller and vary in height, so the overscan is generous.
|
||||
const ROWS = 28;
|
||||
const TICK_MS = 1100;
|
||||
|
||||
type Kind = "raw" | "num" | "link";
|
||||
type Pair = [key: string, value: string, kind?: Kind];
|
||||
type Entry = { level: "info" | "warn" | "error"; pairs: Pair[] };
|
||||
|
||||
// Cycled deterministically so the server and the client render the same first
|
||||
// paint. Nothing here reads the clock or Math.random for the same reason.
|
||||
const ENTRIES: Entry[] = [
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "RSS sync completed. 12 reports downloaded"],
|
||||
["app", "sonarr"],
|
||||
["indexers", "4", "num"],
|
||||
["duration_ms", "812", "num"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Grabbed: The Expanse - S04E03 [WEBDL-1080p]"],
|
||||
["indexer", "NZBgeek"],
|
||||
["size", "3.4 GB"],
|
||||
["client", "sabnzbd"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
pairs: [
|
||||
["level", "warn"],
|
||||
["indexer", "Nyaa"],
|
||||
["attempt", "2", "num"],
|
||||
["maxRetries", "3", "num"],
|
||||
["error", "request timed out after 15s"],
|
||||
["message", "indexer query failed, falling back"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "GET /api/v3/queue => HTTP 200 (4.118ms)"],
|
||||
["url.full", "http://sonarr.lan:8989/api/v3/queue", "link"],
|
||||
["client.ip", "10.0.1.42"],
|
||||
["http.response.status_code", "200", "num"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "error",
|
||||
pairs: [
|
||||
["level", "error"],
|
||||
["error", "dial tcp 10.0.1.7:8080: connection refused"],
|
||||
["component", "qbittorrent"],
|
||||
["message", "download client unreachable, retrying in 30s"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Imported: Dune Part Two (2024) [Bluray-2160p]"],
|
||||
["path", "/media/movies/Dune Part Two (2024)"],
|
||||
["size", "64.2 GB"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
pairs: [
|
||||
["level", "warn"],
|
||||
["free", "42.1 GB"],
|
||||
["threshold", "50 GB"],
|
||||
["mount", "/media"],
|
||||
["message", "Disk space below threshold, pausing grabs"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Health check passed"],
|
||||
["checks", "9", "num"],
|
||||
["app", "prowlarr"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "POST /api/v3/command => HTTP 201 (18.302ms)"],
|
||||
["url.full", "http://radarr.lan:7878/api/v3/command", "link"],
|
||||
["command", "RefreshMovie"],
|
||||
["http.request.method", "POST"],
|
||||
["event.duration", "18302", "num"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
pairs: [
|
||||
["level", "warn"],
|
||||
["release", "Breaking.Bad.S03.COMPLETE.1080p.BluRay"],
|
||||
["reason", "no matching series in library"],
|
||||
["message", "Import failed, moving to unsorted"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Renamed 24 episode files"],
|
||||
["series", "Better Call Saul"],
|
||||
["duration_ms", "412", "num"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "error",
|
||||
pairs: [
|
||||
["level", "error"],
|
||||
["error", "ffprobe exited with code 1"],
|
||||
["file", "/media/movies/Arrival (2016)/Arrival.2016.mkv"],
|
||||
["message", "failed to probe media file, skipping"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Transcoding started"],
|
||||
["app", "jellyfin"],
|
||||
["session", "8f6faccf-154a-408d"],
|
||||
["profile", "1080p H.264"],
|
||||
["client", "Apple TV"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
pairs: [
|
||||
["level", "warn"],
|
||||
["indexer", "1337x"],
|
||||
["failures", "5", "num"],
|
||||
["disabled_until", "2026-08-09T22:14:00Z"],
|
||||
["message", "Indexer temporarily disabled after repeated failures"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Notification sent to Discord"],
|
||||
["event", "Download"],
|
||||
["app", "overseerr"],
|
||||
],
|
||||
},
|
||||
{
|
||||
level: "info",
|
||||
pairs: [
|
||||
["level", "INFO", "raw"],
|
||||
["msg", "Refreshed metadata for 312 series"],
|
||||
["duration_ms", "1840", "num"],
|
||||
["app", "sonarr"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 12:42:25 PM on 08/09/2026, advancing ~7s per entry. Pure arithmetic keeps it
|
||||
// stable across SSR and hydration.
|
||||
const BASE_SECONDS = 12 * 3600 + 42 * 60 + 25;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function entryAt(index: number) {
|
||||
const entry = ENTRIES[index % ENTRIES.length];
|
||||
const total = BASE_SECONDS + index * 7;
|
||||
const hours = Math.floor(total / 3600) % 24;
|
||||
const meridiem = hours >= 12 ? "PM" : "AM";
|
||||
const time = `${pad(hours % 12 || 12)}:${pad(Math.floor(total / 60) % 60)}:${pad(total % 60)} ${meridiem}`;
|
||||
return { ...entry, id: index, date: `08/09/2026 ${time}` };
|
||||
}
|
||||
|
||||
const cursor = ref(ROWS);
|
||||
const rows = computed(() => Array.from({ length: ROWS }, (_, i) => entryAt(cursor.value - ROWS + i)));
|
||||
|
||||
// Deterministic pseudo-random bars for the CPU and memory sparklines.
|
||||
const bars = (seed: number, count: number, floor: number) =>
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const n = Math.sin((i + 1) * seed) * 10000;
|
||||
return floor + (n - Math.floor(n)) * (100 - floor);
|
||||
});
|
||||
|
||||
const cpuBars = bars(12.9898, 44, 18);
|
||||
const memBars = bars(78.233, 44, 55);
|
||||
|
||||
const list = ref<HTMLElement>();
|
||||
const reducedMotion = usePreferredReducedMotion();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
if (reducedMotion.value === "reduce") return;
|
||||
timer = setInterval(async () => {
|
||||
// The list is bottom-anchored, so appending an entry shifts everything up by
|
||||
// the height of the entry dropping off the top. Measure it before the
|
||||
// re-render, then play that shift back as motion.
|
||||
const shift = list.value?.firstElementChild?.getBoundingClientRect().height ?? 0;
|
||||
cursor.value++;
|
||||
await nextTick();
|
||||
list.value?.animate([{ transform: `translateY(${shift}px)` }, { transform: "none" }], {
|
||||
duration: 260,
|
||||
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
});
|
||||
}, TICK_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => clearInterval(timer));
|
||||
|
||||
const alt = "The Dozzle interface streaming container logs in real time";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hero-frame">
|
||||
<div class="hero-demo" role="img" :aria-label="alt">
|
||||
<div class="sidebar" aria-hidden="true">
|
||||
<div class="brand">
|
||||
<img :src="logo" alt="" class="logo" />
|
||||
<span class="wordmark">Dozzle</span>
|
||||
</div>
|
||||
|
||||
<div class="crumbs">
|
||||
<span class="crumb-link">Hosts</span>
|
||||
<span class="crumb-sep">›</span>
|
||||
<span class="host-pill"><i class="i-merge" />nas</span>
|
||||
<span class="crumb-more">⋮</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="group"
|
||||
v-for="group in [
|
||||
{
|
||||
icon: 'pin',
|
||||
label: 'Pinned',
|
||||
items: [{ name: 'media_sonarr.1', active: true }, { name: 'media_jellyfin.1' }],
|
||||
},
|
||||
{
|
||||
icon: 'stack',
|
||||
label: 'media',
|
||||
items: [
|
||||
{ name: 'media_bazarr.1' },
|
||||
{ name: 'media_lidarr.1' },
|
||||
{ name: 'media_overseerr.1', healthy: true },
|
||||
{ name: 'media_prowlarr.1', healthy: true },
|
||||
{ name: 'media_qbittorrent.1' },
|
||||
{ name: 'media_radarr.1' },
|
||||
{ name: 'media_sabnzbd.1' },
|
||||
],
|
||||
},
|
||||
{ icon: 'box', label: 'Running Containers', items: [{ name: 'traefik_traefik.1' }] },
|
||||
]"
|
||||
:key="group.label"
|
||||
>
|
||||
<div class="group-head">
|
||||
<i class="i-group" :class="`i-${group.icon}`" />
|
||||
<span class="group-label">{{ group.label }} ({{ group.items.length }})</span>
|
||||
<span class="merge-btn"><i class="i-merge" /></span>
|
||||
<span class="chevron">⌃</span>
|
||||
</div>
|
||||
<div class="item" v-for="item in group.items" :key="item.name" :class="{ active: item.active }">
|
||||
<span class="dot" />
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<span class="health" v-if="item.healthy">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main" aria-hidden="true">
|
||||
<div class="topbar">
|
||||
<span class="star">★</span>
|
||||
<span class="name-btn">media_sonarr.1 <span class="caret">▾</span></span>
|
||||
<span class="health topbar-health">✓</span>
|
||||
<span class="image-tag">
|
||||
<span class="image-name">lscr.io/linuxserver/sonarr:4.0.15</span>
|
||||
<span class="copy">⧉</span>
|
||||
</span>
|
||||
|
||||
<div class="cards">
|
||||
<div class="io-card">
|
||||
<span class="io-icon">◇</span>
|
||||
<span class="up">↑</span><span class="io-num">235.5K/s</span> <span class="down">↓</span
|
||||
><span class="io-num">355.1K/s</span>
|
||||
<span class="io-icon">▤</span>
|
||||
<span class="up">↑</span><span class="io-num">0B/s</span> <span class="down">↓</span
|
||||
><span class="io-num">56K/s</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card cpu">
|
||||
<div class="stat-head">
|
||||
<span class="stat-icon">▢</span><b>1.7%</b><span class="stat-sub">/ 2 CPU</span>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<i v-for="(h, i) in cpuBars" :key="i" :style="{ height: `${h}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card mem">
|
||||
<div class="stat-head">
|
||||
<span class="stat-icon">▤</span><b>95.6M</b><span class="stat-sub">/ 1.9G</span>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<i v-for="(h, i) in memBars" :key="i" :style="{ height: `${h}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs">
|
||||
<div class="log-list" ref="list">
|
||||
<div v-for="row in rows" :key="row.id" class="row" :class="{ zebra: row.id % 2 === 0 }">
|
||||
<span class="date">{{ row.date }}</span>
|
||||
<span class="level" :class="row.level" />
|
||||
<span class="pairs">
|
||||
<span class="pair" v-for="[key, value, kind] in row.pairs" :key="key">
|
||||
<span class="key">{{ key }}=</span><span class="value" :class="kind ?? 'string'">{{ value }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-frame {
|
||||
/* Inline-size containment means the width can't come from the contents, so
|
||||
the frame has to be sized by its parent. */
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
overflow: hidden;
|
||||
border: 1px solid oklch(90% 0 0);
|
||||
border-radius: 0.5rem;
|
||||
filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
|
||||
}
|
||||
|
||||
.dark .hero-frame {
|
||||
border-color: oklch(22% 0 0);
|
||||
}
|
||||
|
||||
/* The daisyUI palette Dozzle itself ships, so the mock matches the real app. */
|
||||
.hero-demo {
|
||||
--base-100: oklch(100% 0 0);
|
||||
--base-200: oklch(97% 0 0);
|
||||
--base-300: oklch(90% 0 0);
|
||||
--content: oklch(33% 0 0);
|
||||
--primary: oklch(79.96% 0.143 176.65);
|
||||
--secondary: oklch(82.67% 0.1538 77.82);
|
||||
--page: var(--base-100);
|
||||
--chip: var(--base-200);
|
||||
--green: oklch(0.62 0.119722 158.82);
|
||||
--orange: oklch(72% 0.16 60);
|
||||
--red: oklch(64% 0.218 28.85);
|
||||
--blue: oklch(55% 0.171 249.5);
|
||||
--zebra: oklch(70.7% 0.022 261.3 / 0.09);
|
||||
|
||||
/* 1u == 1px at the 1280x760 design size, so the whole mock scales as one unit. */
|
||||
--u: calc(100cqw / 1280);
|
||||
|
||||
display: flex;
|
||||
aspect-ratio: 1280 / 760;
|
||||
background: var(--page);
|
||||
color: var(--content);
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: calc(11 * var(--u));
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dark .hero-demo {
|
||||
--base-100: oklch(25% 0 0);
|
||||
--base-200: oklch(18% 0 0);
|
||||
--base-300: oklch(11% 0 0);
|
||||
--content: oklch(89.23% 0 0);
|
||||
--primary: oklch(70.96% 0.143 176.65);
|
||||
--secondary: oklch(81.38% 0.1448 90.1243);
|
||||
--page: var(--base-300);
|
||||
--chip: var(--base-100);
|
||||
--orange: oklch(85% 0.186 48.13);
|
||||
--blue: oklch(65% 0.171 249.5);
|
||||
--zebra: oklch(70.7% 0.022 261.3 / 0.07);
|
||||
}
|
||||
|
||||
/* Sidebar
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex: 0 0 calc(228 * var(--u));
|
||||
flex-direction: column;
|
||||
padding: calc(12 * var(--u));
|
||||
border-right: 1px solid color-mix(in oklab, var(--content) 10%, transparent);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: calc(34 * var(--u));
|
||||
height: calc(34 * var(--u));
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-size: calc(34 * var(--u));
|
||||
font-weight: 100;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(6 * var(--u));
|
||||
margin-top: calc(18 * var(--u));
|
||||
font-size: calc(11.5 * var(--u));
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.host-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(4 * var(--u));
|
||||
padding: calc(2 * var(--u)) calc(7 * var(--u));
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: calc(4 * var(--u));
|
||||
color: var(--primary);
|
||||
font-size: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
.crumb-more {
|
||||
margin-left: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-top: calc(14 * var(--u));
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(7 * var(--u));
|
||||
padding: calc(2 * var(--u)) calc(4 * var(--u));
|
||||
color: color-mix(in oklab, var(--content) 80%, transparent);
|
||||
font-size: calc(11.5 * var(--u));
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.merge-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(15 * var(--u));
|
||||
height: calc(15 * var(--u));
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: calc(3 * var(--u));
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
margin-left: auto;
|
||||
opacity: 0.45;
|
||||
font-size: calc(9 * var(--u));
|
||||
}
|
||||
|
||||
/* ph:arrows-merge, drawn small enough that a glyph would not survive. */
|
||||
.i-merge {
|
||||
width: calc(9 * var(--u));
|
||||
height: calc(9 * var(--u));
|
||||
background: currentColor;
|
||||
mask: no-repeat center / contain
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M12 21V11m0 0L6 5m6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.i-group {
|
||||
width: calc(12 * var(--u));
|
||||
height: calc(12 * var(--u));
|
||||
background: currentColor;
|
||||
mask: no-repeat center / contain var(--glyph);
|
||||
}
|
||||
|
||||
.i-pin {
|
||||
--glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M12 22v-8m0 0a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.i-stack {
|
||||
--glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='m12 3 9 5-9 5-9-5 9-5Zm9 9-9 5-9-5m18 4-9 5-9-5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.i-box {
|
||||
--glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M3 7h18v10H3zM7 7v10m10-10v10'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8 * var(--u));
|
||||
height: calc(28 * var(--u));
|
||||
padding: 0 calc(10 * var(--u));
|
||||
border-radius: calc(5 * var(--u));
|
||||
font-size: calc(12 * var(--u));
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
color-mix(in oklab, var(--primary) 85%, transparent),
|
||||
color-mix(in oklab, var(--primary) 55%, transparent)
|
||||
);
|
||||
color: oklch(20% 0 0);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: calc(6 * var(--u));
|
||||
height: calc(6 * var(--u));
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.item.active .dot {
|
||||
background: oklch(30% 0.08 158);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.health {
|
||||
margin-left: auto;
|
||||
color: var(--green);
|
||||
font-size: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
.item.active .health {
|
||||
color: oklch(25% 0.06 158);
|
||||
}
|
||||
|
||||
/* Main panel
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex: 0 0 calc(46 * var(--u));
|
||||
align-items: center;
|
||||
gap: calc(8 * var(--u));
|
||||
padding: 0 calc(12 * var(--u));
|
||||
}
|
||||
|
||||
.star {
|
||||
color: var(--secondary);
|
||||
font-size: calc(13 * var(--u));
|
||||
}
|
||||
|
||||
.name-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(5 * var(--u));
|
||||
padding: calc(3 * var(--u)) calc(8 * var(--u));
|
||||
border-radius: calc(4 * var(--u));
|
||||
background: var(--chip);
|
||||
font-size: calc(11 * var(--u));
|
||||
}
|
||||
|
||||
.caret {
|
||||
font-size: calc(8 * var(--u));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.topbar-health {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.image-tag {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: calc(6 * var(--u));
|
||||
white-space: nowrap;
|
||||
padding: calc(3 * var(--u)) calc(4 * var(--u)) calc(3 * var(--u)) calc(8 * var(--u));
|
||||
border-radius: calc(3 * var(--u));
|
||||
background: var(--chip);
|
||||
font-size: calc(10.5 * var(--u));
|
||||
}
|
||||
|
||||
.image-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(14 * var(--u));
|
||||
height: calc(14 * var(--u));
|
||||
border-radius: calc(3 * var(--u));
|
||||
background: color-mix(in oklab, var(--content) 10%, transparent);
|
||||
color: color-mix(in oklab, var(--content) 45%, transparent);
|
||||
font-size: calc(9 * var(--u));
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: stretch;
|
||||
gap: calc(10 * var(--u));
|
||||
margin-left: auto;
|
||||
padding-left: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
.io-card {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: calc(3 * var(--u)) calc(6 * var(--u));
|
||||
grid-template-columns: auto auto calc(44 * var(--u)) auto calc(44 * var(--u));
|
||||
padding: calc(4 * var(--u)) calc(8 * var(--u));
|
||||
border-radius: calc(5 * var(--u));
|
||||
background: color-mix(in oklab, var(--content) 6%, transparent);
|
||||
font-size: calc(10.5 * var(--u));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.io-icon {
|
||||
color: color-mix(in oklab, var(--content) 60%, transparent);
|
||||
}
|
||||
|
||||
.io-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.up {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.down {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(2 * var(--u));
|
||||
width: calc(196 * var(--u));
|
||||
padding: calc(4 * var(--u)) calc(8 * var(--u));
|
||||
border-radius: calc(5 * var(--u));
|
||||
}
|
||||
|
||||
.cpu {
|
||||
background: color-mix(in oklab, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.mem {
|
||||
background: color-mix(in oklab, var(--secondary) 12%, transparent);
|
||||
}
|
||||
|
||||
.stat-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(6 * var(--u));
|
||||
font-size: calc(10.5 * var(--u));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cpu .stat-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.mem .stat-icon {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
color: color-mix(in oklab, var(--content) 45%, transparent);
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: calc(2 * var(--u));
|
||||
height: calc(16 * var(--u));
|
||||
}
|
||||
|
||||
.chart i {
|
||||
flex: 1;
|
||||
min-height: 1px;
|
||||
border-radius: calc(2 * var(--u)) calc(2 * var(--u)) 0 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cpu .chart i {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.mem .chart i {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
/* Log stream
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.logs {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
/* Fades the partially clipped overscan entry so the top edge reads as a
|
||||
scrolled-past tail rather than a cut-off row. */
|
||||
mask-image: linear-gradient(to bottom, transparent, black calc(34 * var(--u)));
|
||||
}
|
||||
|
||||
/* Anchored to the bottom so the newest entry sits on the edge and the overscan
|
||||
clips off the top. */
|
||||
.log-list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: calc(8 * var(--u));
|
||||
padding: calc(4 * var(--u)) calc(14 * var(--u));
|
||||
line-height: calc(16 * var(--u));
|
||||
}
|
||||
|
||||
.row.zebra {
|
||||
background: var(--zebra);
|
||||
}
|
||||
|
||||
.date {
|
||||
flex: none;
|
||||
padding: calc(1 * var(--u)) calc(7 * var(--u));
|
||||
border-radius: calc(3 * var(--u));
|
||||
background: var(--chip);
|
||||
color: var(--blue);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.level {
|
||||
flex: none;
|
||||
width: calc(9 * var(--u));
|
||||
height: calc(9 * var(--u));
|
||||
margin-top: calc(4 * var(--u));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.level.info {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.level.warn {
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
.level.error {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: calc(14 * var(--u));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pair {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.key {
|
||||
color: color-mix(in oklab, var(--content) 70%, transparent);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--content);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.value.string::before,
|
||||
.value.string::after,
|
||||
.value.link::before,
|
||||
.value.link::after {
|
||||
content: '"';
|
||||
}
|
||||
|
||||
.value.link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: calc(3 * var(--u));
|
||||
}
|
||||
|
||||
/* Narrow hero: drop the sidebar and the stat cards, and scale the type against a
|
||||
smaller design width so the entries stay legible instead of collapsing into
|
||||
texture. */
|
||||
@container (max-width: 620px) {
|
||||
.hero-demo {
|
||||
--u: calc(100cqw / 520);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.cards {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* No room to keep a pair intact at this width; let long values break rather
|
||||
than run off the edge. */
|
||||
.pair {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart i {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePreferredReducedMotion } from "@vueuse/core";
|
||||
import { useData } from "vitepress";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import darkPoster from "../media/poster-dark.webp";
|
||||
import lightPoster from "../media/poster-light.webp";
|
||||
import darkVideo from "../media/dozzle-dark.mp4";
|
||||
import lightVideo from "../media/dozzle-light.mp4";
|
||||
|
||||
const { isDark } = useData();
|
||||
const reducedMotion = usePreferredReducedMotion();
|
||||
|
||||
// The posters are rendered on the server and toggled with CSS so the first paint
|
||||
// is already the right theme. The video only mounts on the client, which keeps
|
||||
// markup identical between SSR and hydration and avoids downloading both files.
|
||||
const mounted = ref(false);
|
||||
onMounted(() => (mounted.value = true));
|
||||
|
||||
const playVideo = computed(() => mounted.value && reducedMotion.value !== "reduce");
|
||||
const video = computed(() => (isDark.value ? darkVideo : lightVideo));
|
||||
const poster = computed(() => (isDark.value ? darkPoster : lightPoster));
|
||||
|
||||
const alt = "The Dozzle interface streaming container logs in real time";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
|
||||
>
|
||||
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
|
||||
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
|
||||
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
|
||||
<video
|
||||
v-if="playVideo"
|
||||
:key="video"
|
||||
class="absolute inset-0 size-full"
|
||||
muted
|
||||
loop
|
||||
autoplay
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-hidden="true"
|
||||
:poster="poster"
|
||||
>
|
||||
<source :src="video" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@ import { Icon } from "@iconify/vue";
|
||||
|
||||
import "@fontsource-variable/playfair-display";
|
||||
import "./style.css";
|
||||
import HeroVideo from "./components/HeroVideo.vue";
|
||||
import HeroDemo from "./components/HeroDemo.vue";
|
||||
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
||||
import Stats from "./components/Stats.vue";
|
||||
import HeroTrust from "./components/HeroTrust.vue";
|
||||
@@ -20,7 +20,7 @@ export default {
|
||||
...DefaultTheme,
|
||||
Layout: () => {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
"home-hero-image": () => h(HeroVideo),
|
||||
"home-hero-image": () => h(HeroDemo),
|
||||
"sidebar-nav-after": () => h(BuyMeCoffee),
|
||||
"home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
|
||||
"home-hero-after": () => [h(InstallCommand), h(Supported)],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Vendored
+1
-1
@@ -14,8 +14,8 @@ declare module 'vue' {
|
||||
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
|
||||
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
|
||||
FinalCta: typeof import('./.vitepress/theme/components/FinalCta.vue')['default']
|
||||
HeroDemo: typeof import('./.vitepress/theme/components/HeroDemo.vue')['default']
|
||||
HeroTrust: typeof import('./.vitepress/theme/components/HeroTrust.vue')['default']
|
||||
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
|
||||
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
Reference in New Issue
Block a user