diff --git a/.changeset/admin-console-film.md b/.changeset/admin-console-film.md new file mode 100644 index 000000000..0c7271fdf --- /dev/null +++ b/.changeset/admin-console-film.md @@ -0,0 +1,5 @@ +--- +"nostream-website": patch +--- + +Replace the home page admin screenshot gallery with an operator console film (explain beats on the hero backdrop, full-frame UI shots) and a pause control; add Framefields sources and ship `admin.mp4` in site media. diff --git a/website/hero-animation/README.md b/website/hero-animation/README.md index 4e0274977..520279a40 100644 --- a/website/hero-animation/README.md +++ b/website/hero-animation/README.md @@ -68,9 +68,31 @@ pnpm run render:release:small # → output/release/nostream-3.4.0-low.mp4 (~2.3 pnpm run frames:release # → output/release/frames/f0060.png … (one still per scene + the end card) ``` +## Admin console film + +**35 s** at 1920×1080. Opens on “Operator Console”, then each feature is a short explanation on the landing-hero backdrop (dot grid, planet, sparkle halo) followed by that screenshot full frame: metrics, settings, NIP-66, notifications. + +1. **Title** — “Operator Console” +2. **Metrics** — explanation, then `metrices.png` full frame +3. **Settings** — explanation, then `settings-editor.png` full frame +4. **NIP-66** — explanation, then `nip66.png` full frame +5. **Alerts** — explanation, then `notification.png` full frame +6. **Close** — back on the hero backdrop + +```bash +pnpm run preview:admin # live preview +pnpm run render:admin # → output/admin/nostream-admin.mp4 +pnpm run render:admin:small # → output/admin/nostream-admin-low.mp4 +pnpm run frames:admin # stills for review / posters +``` + +After changing the admin film, run `pnpm run media` in `website/site` to re-encode `public/media/admin.mp4` (Cloudflare Workers assets must stay under 25 MiB — do not copy the raw Framefields mp4 into `public/`). + ## Edit the story - Timeline & layout: `src/film.ts` (scene windows `HERO`, `WHAT`, `FLOW`, `FEATURES`, `DEPLOY`) - Release film: `src/release-film.ts` (scene windows `PROBLEM`, `WAIT`, `DOCS`, `CLOSE`), rendered by `src/release-render.ts` +- Admin film: `src/admin-film.ts`, rendered by `src/admin-render.ts` - Palette & fonts: `src/theme.ts` - Logos / planet / grid: `src/build-assets.ts` (source SVGs in `assets/icons-raw/`, Nostr PNG in `assets/logos/`) +- Admin screenshots: `website/site/src/assets/screens/*.png` diff --git a/website/hero-animation/package.json b/website/hero-animation/package.json index 1b6b453aa..07148a32f 100644 --- a/website/hero-animation/package.json +++ b/website/hero-animation/package.json @@ -16,6 +16,10 @@ "render:release": "tsx src/release-render.ts video very_high", "render:release:small": "tsx src/release-render.ts video low", "frames:release": "tsx src/release-render.ts frames 60 300 650 1000 1199", + "preview:admin": "tsx src/admin-render.ts preview --open", + "render:admin": "tsx src/admin-render.ts video very_high", + "render:admin:small": "tsx src/admin-render.ts video low", + "frames:admin": "tsx src/admin-render.ts frames 50 140 250 450 670 860 1040", "typecheck": "tsc -p ." }, "engines": { diff --git a/website/hero-animation/src/admin-film.ts b/website/hero-animation/src/admin-film.ts new file mode 100644 index 000000000..644f8677a --- /dev/null +++ b/website/hero-animation/src/admin-film.ts @@ -0,0 +1,366 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Composition, Layer, LayerAnimation, Vignette } from 'framefields' +import { + BG, + BODY, + FG, + FONT_MONO, + FONT_SANS, + FPS, + H, + MUTED, + PINK, + TEAL, + W, +} from './theme.js' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const SITE_SCREENS = path.join(ROOT, '..', 'site', 'src', 'assets', 'screens') +const SITE_LOGO = path.join(ROOT, '..', 'site', 'src', 'assets', 'logo.png') +const asset = (name: string) => path.join(ROOT, 'assets', 'gen', `${name}.png`) +const screen = (file: string) => path.join(SITE_SCREENS, file) + +/** + * Admin console film. + * Talk beats use the landing-hero backdrop (dot grid, planet, sparkle halo). + * Each beat is a short explanation, then that screen full-frame. + * 35.3 s @ 30 fps. + */ +export const DURATION_ADMIN = 1060 + +type Node = + | ReturnType + | ReturnType + | ReturnType + | ReturnType +type Key = [number, number] | [number, number, string] +type Window = [number, number] + +const LAST = DURATION_ADMIN - 1 + +const TITLE = { start: 0, end: 100 } +const METRICS_SAY = { start: 100, end: 190 } +const METRICS_SEE = { start: 190, end: 310 } +const SETTINGS_SAY = { start: 310, end: 400 } +const SETTINGS_SEE = { start: 400, end: 520 } +const NIP_SAY = { start: 520, end: 610 } +const NIP_SEE = { start: 610, end: 730 } +const NOTE_SAY = { start: 730, end: 820 } +const NOTE_SEE = { start: 820, end: 940 } +const CLOSE = { start: 940, end: DURATION_ADMIN } + +const TALK: Window[] = [ + [TITLE.start, TITLE.end], + [METRICS_SAY.start, METRICS_SAY.end], + [SETTINGS_SAY.start, SETTINGS_SAY.end], + [NIP_SAY.start, NIP_SAY.end], + [NOTE_SAY.start, NOTE_SAY.end], + [CLOSE.start, CLOSE.end], +] + +const SHOT = { + metrices: { w: 2046, h: 1434, file: 'metrices.png' }, + nip66: { w: 2102, h: 1294, file: 'nip66.png' }, + settingsEditor: { w: 2046, h: 906, file: 'settings-editor.png' }, + notification: { w: 2086, h: 1466, file: 'notification.png' }, +} as const + +type ShotKey = keyof typeof SHOT + +function mergeWindows(windows: Window[], gap: number): Window[] { + const sorted = [...windows].sort((a, b) => a[0] - b[0]) + const out: Window[] = [] + for (const w of sorted) { + const prev = out[out.length - 1] + if (prev && w[0] <= prev[1] + gap) prev[1] = Math.max(prev[1], w[1]) + else out.push([...w]) + } + return out +} + +function windowKeys(windows: Window[], peak = 1, inF = 14, outF = 14): Key[] { + const keys: Key[] = [] + let last = -1 + const push = (f: number, v: number, ease?: string) => { + const frame = Math.max(0, Math.min(LAST, Math.round(f))) + if (frame <= last) return + keys.push(ease ? [frame, v, ease] : [frame, v]) + last = frame + } + const merged = mergeWindows(windows, inF + outF) + if (merged[0][0] > 0) push(0, 0) + for (const [s, e] of merged) { + push(s, 0) + push(s + inF, peak, 'power2.out') + if (e < DURATION_ADMIN) { + push(e - outF, peak) + push(e, 0, 'power2.in') + } + } + return keys +} + +interface Show { + start: number + end?: number + dy?: number + inF?: number + outF?: number +} + +function show(y: number, { start, end = DURATION_ADMIN, dy = 0, inF = 14, outF = 12 }: Show) { + const anim = LayerAnimation.create().keys('opacity', windowKeys([[start, end]], 1, inF, outF)) + if (dy) { + const yk: Key[] = [ + [start, y + dy], + [Math.min(LAST, start + inF + 8), y, 'power3.out'], + ] + if (end < DURATION_ADMIN) { + yk.push([Math.max(start + inF + 10, end - outF), y]) + yk.push([end, y - dy * 0.45, 'power2.in']) + } + anim.keys('y', yk) + } + return anim +} + +const mono = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_MONO, + fontSize: size, + fill: BODY, + opacity: 0, + ...opts, + }) + +const sans = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_SANS, + fontSize: size, + fontWeight: 800, + fill: FG, + letterSpacing: -2, + opacity: 0, + ...opts, + }) + +/** Entire screenshot, inset so a frame and bloom can sit around it. */ +function contain(name: ShotKey, maxW: number, maxH: number) { + const { w, h } = SHOT[name] + const s = Math.min(maxW / w, maxH / h) + const dw = Math.round(w * s) + const dh = Math.round(h * s) + return { w: dw, h: dh } +} + +const rect = (x: number, y: number, w: number, h: number, opts: Record = {}) => + Layer.box({ position: 'absolute', x, y, width: w, height: h, opacity: 0, ...opts }) + +export async function buildFilm(): Promise { + const pending: { node: Node; anim?: LayerAnimation }[] = [] + const add = (node: Node, anim?: LayerAnimation) => pending.push({ node, anim }) + + const hx = 160 + + // ── Same backdrop as the landing hero ──────────────────────────────────── + add( + Layer.image(asset('grid'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0 }), + LayerAnimation.create().keys('opacity', windowKeys(TALK, 1, 16, 12)), + ) + + const planetSize = 1700 + const haloSize = planetSize * (2000 / 1600) + const planetX = 1000 + const planetY: Key[] = [ + [0, 1100], + [48, 520, 'power3.out'], + [LAST, 500], + ] + add( + Layer.image(asset('planet'), { + position: 'absolute', + x: planetX, + y: 1100, + width: planetSize, + height: planetSize, + opacity: 0, + }), + LayerAnimation.create() + .keys('y', planetY) + .keys('opacity', windowKeys(TALK, 1, 16, 12)) + .fromTo('rotation', 0, 42, { start: 0, end: LAST, ease: 'linear' }), + ) + const haloOffset = (haloSize - planetSize) / 2 + add( + Layer.image(asset('sparkles'), { + position: 'absolute', + x: planetX - haloOffset, + y: 1100 - haloOffset, + width: haloSize, + height: haloSize, + opacity: 0, + }), + LayerAnimation.create() + .keys( + 'y', + planetY.map(([f, v, e]) => (e ? [f, (v as number) - haloOffset, e] : [f, (v as number) - haloOffset]) as Key), + ) + .keys('opacity', windowKeys(TALK, 1, 16, 12)) + .fromTo('rotation', 8, -28, { start: 0, end: LAST, ease: 'linear' }), + ) + + add( + Layer.image(SITE_LOGO, { position: 'absolute', x: 118, y: 41, width: 24, height: 24, opacity: 0 }), + LayerAnimation.create().keys('opacity', windowKeys(TALK, 1, 12, 10)), + ) + add( + mono('nostream', 150, 37, 22, { fill: FG, fontWeight: 700 }), + LayerAnimation.create().keys('opacity', windowKeys(TALK, 1, 12, 10)), + ) + + const explain = (kicker: string, head: string, accent: string, body: string, win: { start: number; end: number }) => { + add(mono(kicker, hx, 250, 15, { letterSpacing: 3, fill: TEAL }), show(250, { start: win.start + 4, end: win.end, dy: 10 })) + add( + rect(hx, 276, 64, 1, { background: TEAL }), + LayerAnimation.create() + .keys('opacity', windowKeys([[win.start + 8, win.end]], 0.85, 10, 10)) + .keys('width', [ + [win.start + 8, 0], + [win.start + 28, 64, 'power2.out'], + [Math.max(win.start + 30, win.end - 12), 64], + [win.end, 0], + ]), + ) + add(sans(head, hx, 300, 78), show(300, { start: win.start + 10, end: win.end, dy: 28, inF: 16 })) + add(sans(accent, hx, 388, 78, { fill: PINK }), show(388, { start: win.start + 20, end: win.end, dy: 24, inF: 16 })) + add(mono(body, hx, 500, 22, { fill: BODY }), show(500, { start: win.start + 36, end: win.end, dy: 12 })) + } + + /** + * Screenshot and its hairline share one box. The rule is a dim 1px edge, + * not a bright stroke that can drift off the bitmap. + */ + const fullShot = (name: ShotKey, label: string, win: { start: number; end: number }) => { + const stroke = 1 + const marginX = 72 + const marginY = 64 + const inner = contain(name, W - marginX * 2, H - marginY * 2 - 28) + const x = Math.round((W - inner.w) / 2) + const y = Math.round((H - inner.h) / 2 - 8) + const fade = () => LayerAnimation.create().keys('opacity', windowKeys([[win.start, win.end]], 1, 14, 12)) + + add( + rect(x - stroke, y - stroke, inner.w + stroke * 2, inner.h + stroke * 2, { + background: BG, + borderColor: '#6e6e78', + borderWidth: stroke, + }), + LayerAnimation.create().keys('opacity', windowKeys([[win.start, win.end]], 0.85, 14, 12)), + ) + + add( + Layer.image(screen(SHOT[name].file), { + position: 'absolute', + x, + y, + width: inner.w, + height: inner.h, + opacity: 0, + }), + fade(), + ) + + add( + mono(label, 0, y + inner.h + 16, 14, { fill: MUTED, letterSpacing: 3, width: W, align: 'center' }), + show(y + inner.h + 16, { start: win.start + 16, end: win.end, dy: 8, inF: 12, outF: 10 }), + ) + } + + // ── Open ───────────────────────────────────────────────────────────────── + add( + sans('Operator', 0, 380, 96, { width: W, align: 'center', letterSpacing: -3 }), + show(380, { start: 12, end: TITLE.end, dy: 28, inF: 18 }), + ) + add( + sans('Console', 0, 484, 96, { width: W, align: 'center', letterSpacing: -3, fill: PINK }), + show(484, { start: 24, end: TITLE.end, dy: 24, inF: 18 }), + ) + add( + mono('Know what your relay is actually doing.', 0, 610, 24, { fill: MUTED, width: W, align: 'center' }), + show(610, { start: 48, end: TITLE.end, dy: 12 }), + ) + + // ── Metrics ────────────────────────────────────────────────────────────── + explain( + 'LIVE METRICS', + 'Watch the load', + 'as it happens.', + 'Events, rejections, connections, CPU and memory — over SSE.', + METRICS_SAY, + ) + fullShot('metrices', '/ADMIN · LIVE METRICS', METRICS_SEE) + + // ── Settings ───────────────────────────────────────────────────────────── + explain( + 'SETTINGS', + 'Change it.', + 'Undo it.', + 'Validated edits, automatic backups, one-click restore.', + SETTINGS_SAY, + ) + fullShot('settingsEditor', '/ADMIN · SETTINGS', SETTINGS_SEE) + + // ── NIP-66 ─────────────────────────────────────────────────────────────── + explain( + 'NIP-66', + 'Reachable from', + 'the outside.', + 'DNS, TLS, WebSocket RTT and NIP-11 — probed per target.', + NIP_SAY, + ) + fullShot('nip66', '/ADMIN · NIP-66', NIP_SEE) + + // ── Notifications ──────────────────────────────────────────────────────── + explain( + 'ALERTS', + 'Hear about trouble', + 'first.', + 'Outbound webhooks, and a log of every delivery.', + NOTE_SAY, + ) + fullShot('notification', '/ADMIN · ALERTS', NOTE_SEE) + + // ── Close, back on the hero backdrop ───────────────────────────────────── + add( + sans('Built into nostream.', 0, 430, 72, { width: W, align: 'center', letterSpacing: -2 }), + show(430, { start: CLOSE.start + 16, dy: 22, inF: 18 }), + ) + add( + mono('nostream start → /admin', 0, 540, 26, { fill: TEAL, width: W, align: 'center' }), + show(540, { start: CLOSE.start + 40, dy: 12, inF: 16 }), + ) + + const film = new Composition({ + width: W, + height: H, + fps: FPS, + durationFrames: DURATION_ADMIN, + backgroundColor: BG, + fonts: [path.join(ROOT, 'assets/fonts/JetBrainsMono.ttf'), path.join(ROOT, 'assets/fonts/Inter.ttf')], + }) + for (const { node, anim } of pending) film.add((anim ? node.animate(anim) : node) as never) + film.add( + Layer.image(asset('grain'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0 }).animate( + LayerAnimation.create().keys('opacity', windowKeys(TALK, 0.85, 12, 10)), + ) as never, + ) + film.apply(new Vignette({ strength: 0.16, radius: 0.95, softness: 0.7 })) + return film +} diff --git a/website/hero-animation/src/admin-render.ts b/website/hero-animation/src/admin-render.ts new file mode 100644 index 000000000..f5af5a885 --- /dev/null +++ b/website/hero-animation/src/admin-render.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { HeadlessMediaRenderer, startPreview } from 'framefields' +import { buildFilm } from './admin-film.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const OUTPUT = path.join(__dirname, '..', 'output', 'admin') + +const [mode = 'preview', ...args] = process.argv.slice(2) + +if (mode === 'preview') { + const session = await startPreview( + { entry: new URL('./admin-film.ts', import.meta.url), export: 'buildFilm' }, + { title: 'nostream admin console', open: args.includes('--open') }, + ) + console.log(`\nPreview running at:\n ${session.url}\n`) + console.log('Leave the tab open while reviewing. Press Ctrl+C here to stop the server.\n') + await session.closed + process.exit(0) +} + +const film = await buildFilm() +await fs.mkdir(path.join(OUTPUT, 'frames'), { recursive: true }) + +if (mode === 'frames') { + const renderer = new HeadlessMediaRenderer() + for (const frame of args.map(Number)) { + const file = path.join(OUTPUT, 'frames', `f${String(frame).padStart(4, '0')}.png`) + await fs.writeFile(file, await film.renderFrame({ frame, renderer })) + console.log(file) + } + process.exit(0) +} + +const quality = args[0] ?? 'very_high' +const started = Date.now() +const result = await film.renderVideo({ + outputPath: path.join(OUTPUT, quality === 'very_high' ? 'nostream-admin.mp4' : `nostream-admin-${quality}.mp4`), + quality, +}) +await result.cleanup?.().catch(() => {}) +console.log(`${result.filePath} (${((Date.now() - started) / 1000).toFixed(1)} s)`) +process.exit(0) diff --git a/website/site/README.md b/website/site/README.md index d269b4bb9..712187884 100644 --- a/website/site/README.md +++ b/website/site/README.md @@ -28,13 +28,15 @@ Media comes from the landing film in `../hero-animation` (needs `ffmpeg` for `pn | Asset | Source | |-------|--------| -| `hero.mp4`, `hero-poster.webp`, `hero.meta.json` | Landing film — `src/film.ts` (`pnpm run render`, `pnpm run frames`) | +| `hero.mp4`, `hero-low.mp4`, `hero-poster.webp`, `hero.meta.json` | Landing film — `src/film.ts` (`pnpm run render`, `pnpm run frames`) | +| `admin.mp4`, `admin-poster.webp` | Operator console film — `src/admin-film.ts` (`pnpm run render:admin`, `pnpm run frames:admin`) | | `planet.webp` | Particle planet — `src/build-assets.ts` (`pnpm run assets`) | ```bash cd website/hero-animation pnpm run assets pnpm run render && pnpm run frames +pnpm run render:admin && pnpm run frames:admin # when the admin film or screenshots change cd ../site pnpm run media ``` @@ -45,4 +47,4 @@ Official logo: `src/assets/logo.png`. Used in nav/footer, favicon (`pnpm run` / ## Admin console screenshots -Drop them into `src/assets/screens/` (see the README there). Missing ones render a placeholder. +Still PNGs for the home-page console film live in `src/assets/screens/` (see the README there). The marketing section plays `public/media/admin.mp4`, not the gallery of static shots. diff --git a/website/site/public/media/admin-poster.webp b/website/site/public/media/admin-poster.webp new file mode 100644 index 000000000..90250631f Binary files /dev/null and b/website/site/public/media/admin-poster.webp differ diff --git a/website/site/public/media/admin.mp4 b/website/site/public/media/admin.mp4 new file mode 100644 index 000000000..4ffd929f0 Binary files /dev/null and b/website/site/public/media/admin.mp4 differ diff --git a/website/site/scripts/build-media.mjs b/website/site/scripts/build-media.mjs index de7710391..f38c7328d 100644 --- a/website/site/scripts/build-media.mjs +++ b/website/site/scripts/build-media.mjs @@ -13,6 +13,8 @@ const LANDING = path.join(HERO_DIR, 'output') const MEDIA = path.join(ROOT, 'public', 'media') const HERO_META = path.join(MEDIA, 'hero.meta.json') const LANDING_POSTER = 'f0130.png' +const ADMIN_LANDING = path.join(LANDING, 'admin') +const ADMIN_POSTER = 'f0050.png' mkdirSync(MEDIA, { recursive: true }) @@ -114,4 +116,21 @@ if (landingVideo) { console.warn('skip hero.mp4: run `pnpm run render` in website/hero-animation first') } +const adminPoster = path.join(ADMIN_LANDING, 'frames', ADMIN_POSTER) +if (existsSync(adminPoster)) { + await sharp(adminPoster).resize(1920).webp({ quality: 90 }).toFile(path.join(MEDIA, 'admin-poster.webp')) + console.log('admin poster → public/media') +} else { + console.warn(`skip admin poster: run \`pnpm run frames:admin\` in website/hero-animation (need frames/${ADMIN_POSTER})`) +} + +const adminVideo = [process.env.ADMIN_SRC, path.join(ADMIN_LANDING, 'nostream-admin.mp4')].find((p) => p && existsSync(p)) + +if (adminVideo) { + encodeHero(adminVideo, path.join(MEDIA, 'admin.mp4'), 15) + console.log('admin.mp4 ← admin film (re-encoded for site / Workers asset limit)') +} else { + console.warn('skip admin.mp4: run `pnpm run render:admin` in website/hero-animation first') +} + console.log('done → public/media/') diff --git a/website/site/src/assets/screens/README.md b/website/site/src/assets/screens/README.md index 62bb73abb..f97d1ffba 100644 --- a/website/site/src/assets/screens/README.md +++ b/website/site/src/assets/screens/README.md @@ -1,13 +1,17 @@ # Admin console screenshots -Drop screenshots here (png, jpg or webp). They are picked up by name on the home page and optimised at build time: +Source stills for the operator-console film (`website/hero-animation/src/admin-film.ts`). After replacing one, re-render the film and publish site media (Workers preview deploy requires every asset under 25 MiB — `pnpm run media` re-encodes the mp4 for `public/`). -| File | Panel | -|------|-------| -| `metrics.png`, `metrics-1.png` | Live metrics (both shown full width, stacked) | -| `network-health.png` | Network Health | -| `mismatches.png` | NIP-11 mismatch warnings | -| `settings.png` | Settings editor | -| `notifications.png` | Notifications | +```bash +cd website/hero-animation +pnpm run render:admin && pnpm run frames:admin # → output/admin/nostream-admin.mp4 (+ frames/) +cd ../site +pnpm run media # → public/media/admin.mp4, admin-poster.webp +``` -Missing files render a placeholder. 16:9 crops at 1920 px wide look best. +| File | Scene | +|------|-------| +| `metrices.png` | Live metrics | +| `nip66.png` | Network health (NIP-66) | +| `settings-editor.png` | Settings editor | +| `notification.png` | Notifications | diff --git a/website/site/src/assets/screens/metrices.png b/website/site/src/assets/screens/metrices.png new file mode 100644 index 000000000..cb9d70f1e Binary files /dev/null and b/website/site/src/assets/screens/metrices.png differ diff --git a/website/site/src/assets/screens/metrics-1.png b/website/site/src/assets/screens/metrics-1.png deleted file mode 100644 index fe2f017a7..000000000 Binary files a/website/site/src/assets/screens/metrics-1.png and /dev/null differ diff --git a/website/site/src/assets/screens/metrics.png b/website/site/src/assets/screens/metrics.png deleted file mode 100644 index 3a7242658..000000000 Binary files a/website/site/src/assets/screens/metrics.png and /dev/null differ diff --git a/website/site/src/assets/screens/mismatches.png b/website/site/src/assets/screens/mismatches.png deleted file mode 100644 index 501d1995e..000000000 Binary files a/website/site/src/assets/screens/mismatches.png and /dev/null differ diff --git a/website/site/src/assets/screens/network-health.png b/website/site/src/assets/screens/network-health.png deleted file mode 100644 index 8124123da..000000000 Binary files a/website/site/src/assets/screens/network-health.png and /dev/null differ diff --git a/website/site/src/assets/screens/nip66.png b/website/site/src/assets/screens/nip66.png new file mode 100644 index 000000000..dc48254c9 Binary files /dev/null and b/website/site/src/assets/screens/nip66.png differ diff --git a/website/site/src/assets/screens/notification.png b/website/site/src/assets/screens/notification.png new file mode 100644 index 000000000..74dba8393 Binary files /dev/null and b/website/site/src/assets/screens/notification.png differ diff --git a/website/site/src/assets/screens/notifications.png b/website/site/src/assets/screens/notifications.png deleted file mode 100644 index c47e26bb8..000000000 Binary files a/website/site/src/assets/screens/notifications.png and /dev/null differ diff --git a/website/site/src/assets/screens/settings-editor.png b/website/site/src/assets/screens/settings-editor.png new file mode 100644 index 000000000..2fa75675c Binary files /dev/null and b/website/site/src/assets/screens/settings-editor.png differ diff --git a/website/site/src/assets/screens/settings.png b/website/site/src/assets/screens/settings.png deleted file mode 100644 index daa39b6c6..000000000 Binary files a/website/site/src/assets/screens/settings.png and /dev/null differ diff --git a/website/site/src/components/home/Console.astro b/website/site/src/components/home/Console.astro index d7871ea60..e0537b3e2 100644 --- a/website/site/src/components/home/Console.astro +++ b/website/site/src/components/home/Console.astro @@ -1,59 +1,38 @@ --- -import type { ImageMetadata } from 'astro' -import { Image } from 'astro:assets' -import ScreenBar from '../ScreenBar.astro' +import FilmFrame from '../FilmFrame.astro' import InfoTip from '../InfoTip.astro' -// Drop admin console screenshots into src/assets/screens/ using these names (png, jpg or webp). -const files = import.meta.glob<{ default: ImageMetadata }>('../../assets/screens/*.{png,jpg,jpeg,webp}', { eager: true }) -const byName = Object.fromEntries( - Object.entries(files).map(([file, mod]) => [file.split('/').pop()!.replace(/\.\w+$/, ''), mod.default]), -) -const shots = [ +const media = (file: string) => `${import.meta.env.BASE_URL}media/${file}`.replace(/\/\//, '/') + +const capabilities = [ + 'Login-gated so only you can reach it', + 'Plugs into the monitoring you already run', + 'Health endpoint for load balancers and zero-downtime deploys', + 'Self-hosted probes, labelled as one monitor among many', +] + +const features = [ { - names: ['metrics', 'metrics-1'], title: 'Live metrics', body: 'Watch traffic and load as it happens, so you catch problems while they are small.', tip: 'Events per second, rejections, connections, CPU and memory, streamed over SSE.', - tag: 'SSE', - main: true, }, { - name: 'network-health', title: 'Network health', body: 'Know your relay is reachable and fast from the outside, not just from your own machine.', tip: 'NIP-66 probes for DNS, TLS, WebSocket read/write RTT and NIP-11, per target.', - tag: 'NIP-66', }, { - name: 'mismatches', - title: 'Honest advertising', - body: "Flags the gap between what your relay advertises and what it actually does — in either direction.", - tip: 'NIP-11 mismatch warnings: compares what your relay publishes in supported_nips with what probes measure, and flags any disagreement.', - tag: 'NIP-11', - }, - { - name: 'settings', title: 'Settings editor', body: 'Change settings confidently — mistakes are caught before they ship, and undo is one click.', tip: 'Guided, validated edits with automatic backups and one-click restore.', - tag: 'YAML', }, { - name: 'notifications', title: 'Notifications', body: 'Hear about trouble first, before your users notice.', tip: 'Outbound alerts with a delivery log, so nothing gets lost silently.', - tag: 'ALERT', }, ] - -const capabilities = [ - 'Login-gated so only you can reach it', - 'Plugs into the monitoring you already run', - 'Health endpoint for load balancers and zero-downtime deploys', - 'Self-hosted probes, labelled as one monitor among many', -] ---
@@ -68,54 +47,38 @@ const capabilities = [
    {capabilities.map((c) =>
  • {c}
  • )}
-

- Screenshots from a real console — this one ran on a laptop catching its first events, rejections and failed - alert deliveries included. -

- +
@@ -143,84 +106,93 @@ const capabilities = [ font-size: 12px; } - .demo-note { - margin: 16px 0 0; - color: var(--fg-2); - font-size: 13px; - } - - .gallery { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 20px 16px; - } - - @media (min-width: 900px) { - .gallery { - grid-template-columns: repeat(2, 1fr); - } - .main { - grid-column: 1 / -1; - } + .film-wrap { + position: relative; + margin-top: 8px; } - .shot { - margin: 0; + .film-wrap :global(.film-frame) { + width: 100%; } - .frame { - overflow: hidden; + .film-toggle { + position: absolute; + z-index: 4; + left: 16px; + bottom: 16px; + padding: 8px 14px; border: 1px solid var(--panel-border); - border-radius: 12px; - background: var(--panel); - transition: border-color 0.25s ease; + border-radius: 7px; + background: rgba(14, 14, 17, 0.8); + color: var(--fg-2); + font-size: 12px; + letter-spacing: 0.08em; + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease; } - .shot:hover .frame { + .film-toggle:hover { border-color: var(--wire); + color: var(--fg); } - .shots.stack { - display: grid; - gap: 1px; - background: var(--line); - } - - .frame :global(img) { - display: block; - width: 100%; - height: auto; + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } - .placeholder { + .feature-list { display: grid; - place-content: center; - justify-items: center; - gap: 12px; - aspect-ratio: 16 / 9; - background: - linear-gradient(135deg, rgba(255, 46, 136, 0.05), transparent 40%), - repeating-linear-gradient(0deg, transparent 0 27px, var(--line) 27px 28px), - repeating-linear-gradient(90deg, transparent 0 27px, rgba(38, 38, 42, 0.5) 27px 28px); - color: var(--fg-2); - font-size: 14px; - } - - .main .placeholder { - aspect-ratio: 21 / 9; - } - - .hint { - color: var(--wire); - font-size: 12px; + grid-template-columns: minmax(0, 1fr); + gap: 20px 16px; + margin: 20px 0 0; + padding: 0; + list-style: none; } - figcaption { - padding: 16px 4px 0; + @media (min-width: 900px) { + .feature-list { + grid-template-columns: repeat(2, 1fr); + } } - figcaption p { + .feature p { margin: 0; font-size: 15px; } + +