docs(screenshots): refresh v0.14 UI shots and add website gallery (#521)

* docs(screenshots): refresh v0.14 settings/About shots and add website gallery

Replace the outdated About dialog screenshot and supersede the old single
settings dialog with the six v0.14.0-beta settings tabs. Keep unique product
shots that have no replacements. Add a dedicated /screenshots/ website page
with header/nav discoverability and a homepage link (no full gallery on home).

* docs(screenshots): drop obsolete features-overview shot

The annotated features overview was the pre-tabbed settings panel and no
longer matches the v0.14 UI. Remove it from README, website gallery, and assets.

* docs(screenshots): ship full-resolution web transcription shot

Restore the original PNG instead of the downscaled copy so the website
gallery matches the repo assets bit-for-bit.

* feat(web): add click-to-expand lightbox on screenshots gallery

Open a full-screen dialog when a shot is clicked, with prev/next controls,
keyboard arrows, Escape to close, and captioned counter for browsing the set.

* fix(web): avoid next/image HMR crash on screenshots gallery

Use plain img tags in the client gallery so dev-mode webpack chunk
reloads do not throw options.factory call errors while hydrating.
This commit is contained in:
Jatin K Malik
2026-07-15 00:18:26 -07:00
committed by GitHub
parent 6545e4ea66
commit eba4801043
28 changed files with 776 additions and 26 deletions
+36 -7
View File
@@ -83,15 +83,17 @@ No internet required. No data leaves your machine. Just speak and type.
## 📸 Screenshots
Here are some screenshots showcasing Vocalinux in action:
Vocalinux in action, plus the settings tabs from v0.14.0-beta. Full gallery on the [website screenshots page](https://vocalinux.com/screenshots/).
### Product
<table>
<tr>
<td align="center">
<td align="center" width="50%">
<img src="resources/screenshots/00-transcription.png" alt="Transcription in Action" width="350"><br>
<em>Real-time voice-to-text transcription</em>
</td>
<td align="center">
<td align="center" width="50%">
<img src="resources/screenshots/02-system-tray.png" alt="System Tray" width="350"><br>
<em>System tray with listening indicator</em>
</td>
@@ -99,17 +101,44 @@ Here are some screenshots showcasing Vocalinux in action:
<tr>
<td align="center">
<img src="resources/screenshots/05-about-view.png" alt="About View" width="350"><br>
<em>About view with version info</em>
<em>About dialog with version info</em>
</td>
<td align="center">
<img src="resources/screenshots/03-log-viewer.png" alt="Log Viewer" width="350"><br>
<em>Log viewer for debugging</em>
</td>
</tr>
</table>
### Settings
<table>
<tr>
<td colspan="2" align="center">
<img src="resources/screenshots/04-features-overview.png" alt="Features Overview" width="500"><br>
<em>Overview of key features and configuration options with annotations</em>
<td align="center" width="33%">
<img src="resources/screenshots/settings-speech-engine.png" alt="Speech Engine settings" width="260"><br>
<em>Speech Engine</em>
</td>
<td align="center" width="33%">
<img src="resources/screenshots/settings-recognition.png" alt="Recognition settings" width="260"><br>
<em>Recognition</em>
</td>
<td align="center" width="33%">
<img src="resources/screenshots/settings-audio.png" alt="Audio settings" width="260"><br>
<em>Audio</em>
</td>
</tr>
<tr>
<td align="center">
<img src="resources/screenshots/settings-shortcuts.png" alt="Shortcuts and hotkeys" width="260"><br>
<em>Shortcuts &amp; Hotkeys</em>
</td>
<td align="center">
<img src="resources/screenshots/settings-general.png" alt="General settings" width="260"><br>
<em>General</em>
</td>
<td align="center">
<img src="resources/screenshots/settings-advanced.png" alt="Advanced tuning and settings" width="260"><br>
<em>Advanced</em>
</td>
</tr>
</table>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+6
View File
@@ -42,6 +42,12 @@
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://vocalinux.com/screenshots/</loc>
<lastmod>2026-07-14</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://vocalinux.com/faq/</loc>
<lastmod>2026-06-11</lastmod>
@@ -0,0 +1,88 @@
import fs from "fs";
import path from "path";
/**
* Structural checks for the screenshots gallery: page source, public assets,
* nav discoverability, and homepage link. Exercises the real shipped files
* under web/ rather than re-implementing gallery data in the test.
*/
describe("Screenshots page and assets", () => {
const webRoot = process.cwd();
const pagePath = path.join(webRoot, "src/app/screenshots/page.tsx");
const shellPath = path.join(webRoot, "src/components/seo-subpage-shell.tsx");
const homePath = path.join(webRoot, "src/app/page.tsx");
const publicScreenshotsDir = path.join(webRoot, "public/screenshots");
const expectedAssets = [
"00-transcription.png",
"02-system-tray.png",
"03-log-viewer.png",
"05-about-view.png",
"settings-speech-engine.png",
"settings-recognition.png",
"settings-audio.png",
"settings-shortcuts.png",
"settings-general.png",
"settings-advanced.png",
];
let pageSource: string;
let shellSource: string;
let homeSource: string;
beforeAll(() => {
pageSource = fs.readFileSync(pagePath, "utf-8");
shellSource = fs.readFileSync(shellPath, "utf-8");
homeSource = fs.readFileSync(homePath, "utf-8");
});
it("ships a dedicated /screenshots page module with lightbox gallery", () => {
expect(fs.existsSync(pagePath)).toBe(true);
expect(pageSource).toMatch(/export default function ScreenshotsPage/);
expect(pageSource).toContain('path: "/screenshots"');
expect(pageSource).toContain("Vocalinux Screenshots");
expect(pageSource).toContain("ScreenshotGallery");
const galleryPath = path.join(
webRoot,
"src/components/screenshot-gallery.tsx",
);
expect(fs.existsSync(galleryPath)).toBe(true);
const gallerySource = fs.readFileSync(galleryPath, "utf-8");
expect(gallerySource).toContain('role="dialog"');
expect(gallerySource).toContain("aria-modal");
expect(gallerySource).toContain("View larger");
});
it("references every public screenshot asset from the page source", () => {
for (const file of expectedAssets) {
const assetPath = path.join(publicScreenshotsDir, file);
expect(fs.existsSync(assetPath)).toBe(true);
expect(fs.statSync(assetPath).size).toBeGreaterThan(1000);
expect(pageSource).toContain(`/screenshots/${file}`);
}
});
it("does not embed a full homepage gallery (link-only on home)", () => {
// Homepage should link to the gallery, not list every settings shot.
expect(homeSource).toContain('href: "/screenshots/"');
expect(homeSource).toContain('href="/screenshots/"');
expect(homeSource).not.toContain("/screenshots/settings-speech-engine.png");
expect(homeSource).not.toContain("/screenshots/05-about-view.png");
});
it("exposes Screenshots in the shared subpage shell nav", () => {
expect(shellSource).toContain('href: "/screenshots/"');
expect(shellSource).toContain('label: "Screenshots"');
});
it("includes captioned settings and product sections", () => {
expect(pageSource).toContain("Speech Engine");
expect(pageSource).toContain("Recognition");
expect(pageSource).toContain("Audio");
expect(pageSource).toContain("Shortcuts");
expect(pageSource).toContain("General");
expect(pageSource).toContain("Advanced");
expect(pageSource).toContain("Transcription in action");
expect(pageSource).toContain("About");
});
});
+1
View File
@@ -37,6 +37,7 @@ describe("SEO Assets", () => {
"https://vocalinux.com/voice-activity-detection/",
"https://vocalinux.com/advanced-settings/",
"https://vocalinux.com/desktop-reliability/",
"https://vocalinux.com/screenshots/",
];
expectedUrls.forEach((url) => {
+59 -19
View File
@@ -309,6 +309,7 @@ export default function HomePage() {
const navLinks = [
{ href: "#demo", label: "Demo" },
{ href: "#features", label: "Features" },
{ href: "/screenshots/", label: "Screenshots" },
{ href: "#install", label: "Install" },
{ href: "#guides", label: "Guides" },
{ href: "#faq", label: "FAQ" },
@@ -346,15 +347,25 @@ export default function HomePage() {
{/* Desktop navigation */}
<div className="hidden items-center gap-6 md:flex">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</a>
))}
{navLinks.map((link) =>
link.href.startsWith("/") ? (
<Link
key={link.href}
href={link.href}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</Link>
) : (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</a>
),
)}
<a
href="https://github.com/jatinkrmalik/vocalinux"
target="_blank"
@@ -399,16 +410,27 @@ export default function HomePage() {
className="overflow-hidden border-b border-border bg-background md:hidden"
>
<div className="space-y-2 px-4 py-3">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="block py-2 text-foreground transition-colors hover:text-primary"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</a>
))}
{navLinks.map((link) =>
link.href.startsWith("/") ? (
<Link
key={link.href}
href={link.href}
className="block py-2 text-foreground transition-colors hover:text-primary"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
) : (
<a
key={link.href}
href={link.href}
className="block py-2 text-foreground transition-colors hover:text-primary"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</a>
),
)}
<a
href="https://github.com/jatinkrmalik/vocalinux"
target="_blank"
@@ -755,6 +777,16 @@ export default function HomePage() {
</div>
</FadeInSection>
<div className="mb-10 text-center">
<Link
href="/screenshots/"
className="border-primary/30 bg-primary/10 text-primary hover:bg-primary/15 inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-colors"
>
See screenshots of the app UI
<ChevronRight className="h-4 w-4" />
</Link>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={<Shield className="h-6 w-6 text-primary" />}
@@ -1851,6 +1883,14 @@ export default function HomePage() {
Install Guide
</Link>
</li>
<li>
<Link
href="/screenshots/"
className="text-zinc-400 transition-colors hover:text-white"
>
Screenshots
</Link>
</li>
<li>
<Link
href="/changelog/"
+189
View File
@@ -0,0 +1,189 @@
import Link from "next/link";
import { type Metadata } from "next";
import { Camera, ChevronRight, Download } from "lucide-react";
import {
ScreenshotGallery,
type Screenshot,
} from "@/components/screenshot-gallery";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
const productShots: Screenshot[] = [
{
src: "/screenshots/00-transcription.png",
alt: "Vocalinux real-time transcription in action",
title: "Transcription in action",
description: "Dictate into any Linux app with real-time voice-to-text.",
width: 2565,
height: 2018,
},
{
src: "/screenshots/02-system-tray.png",
alt: "Vocalinux system tray listening indicator",
title: "System tray",
description: "Tray icon shows idle, listening, and processing states.",
width: 565,
height: 453,
},
{
src: "/screenshots/05-about-view.png",
alt: "Vocalinux About dialog",
title: "About",
description: "Version info, credits, and project links.",
width: 1012,
height: 854,
},
{
src: "/screenshots/03-log-viewer.png",
alt: "Vocalinux log viewer dialog",
title: "Log viewer",
description: "Inspect runtime logs when diagnosing dictation issues.",
width: 1904,
height: 1578,
},
];
const settingsShots: Screenshot[] = [
{
src: "/screenshots/settings-speech-engine.png",
alt: "Speech Engine settings tab",
title: "Speech Engine",
description:
"Choose whisper.cpp, Whisper, VOSK, or Remote API and pick a model.",
width: 1504,
height: 2146,
},
{
src: "/screenshots/settings-recognition.png",
alt: "Recognition settings tab",
title: "Recognition",
description: "Language, voice commands, and recognition behavior.",
width: 1504,
height: 2146,
},
{
src: "/screenshots/settings-audio.png",
alt: "Audio settings tab",
title: "Audio",
description: "Input device, feedback sounds, and capture options.",
width: 1504,
height: 2146,
},
{
src: "/screenshots/settings-shortcuts.png",
alt: "Shortcuts and hotkeys settings tab",
title: "Shortcuts & Hotkeys",
description: "Toggle mode, push-to-talk, and custom key bindings.",
width: 1504,
height: 2146,
},
{
src: "/screenshots/settings-general.png",
alt: "General settings tab",
title: "General",
description: "Autostart, UI preferences, and everyday defaults.",
width: 1504,
height: 2146,
},
{
src: "/screenshots/settings-advanced.png",
alt: "Advanced tuning and settings tab",
title: "Advanced",
description: "Power-user decoding controls and Remote Server options.",
width: 1504,
height: 2146,
},
];
export const metadata: Metadata = buildPageMetadata({
title: "Vocalinux Screenshots | Linux Voice Dictation UI",
description:
"See Vocalinux in action: system tray, about dialog, log viewer, and the full settings tabs for speech engine, recognition, audio, shortcuts, general, and advanced options.",
path: "/screenshots",
keywords: [
"Vocalinux screenshots",
"Linux voice dictation UI",
"offline speech recognition settings",
"whisper.cpp Linux screenshots",
"voice typing system tray Linux",
],
});
export default function ScreenshotsPage() {
const articleJsonLd = {
"@context": "https://schema.org",
"@type": "ImageGallery",
name: "Vocalinux Screenshots",
description:
"Product and settings screenshots of Vocalinux, offline voice dictation for Linux.",
dateModified: "2026-07-15",
author: {
"@type": "Person",
name: "Jatin K Malik",
url: "https://github.com/jatinkrmalik",
},
publisher: {
"@type": "Organization",
name: "Vocalinux",
logo: {
"@type": "ImageObject",
url: absoluteUrl("/vocalinux.png"),
},
},
mainEntityOfPage: absoluteUrl("/screenshots"),
};
return (
<SeoSubpageShell>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<section>
<p className="border-primary/30 bg-primary/10 mb-4 inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-sm font-medium text-primary">
<Camera className="h-4 w-4" />
v0.14.0 Beta UI
</p>
<h1 className="mb-5 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl">
Vocalinux Screenshots
</h1>
<p className="mb-8 max-w-4xl text-lg text-muted-foreground">
A look at offline voice dictation on Linux: tray controls, debugging
tools, and the full settings dialog. Click any image to expand it and
browse the gallery. These shots match the current v0.14.0-beta UI.
</p>
</section>
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>
<section className="border-primary/20 bg-primary/5 rounded-2xl border p-8">
<h2 className="mb-3 text-2xl font-bold">Try it yourself</h2>
<p className="mb-6 max-w-2xl text-muted-foreground">
Install Vocalinux and open Settings from the system tray to explore
the same screens on your desktop.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
href="/install/"
className="hover:bg-primary/90 inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground dark:text-black"
>
<Download className="h-4 w-4" />
Install guide
<ChevronRight className="h-4 w-4" />
</Link>
<Link
href="/advanced-settings/"
className="inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-300 bg-white px-5 py-3 text-sm font-semibold transition-colors hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:hover:bg-zinc-700"
>
Advanced settings guide
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</section>
</SeoSubpageShell>
);
}
@@ -0,0 +1,129 @@
import React from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
ScreenshotGallery,
type Screenshot,
} from "../screenshot-gallery";
const productShots: Screenshot[] = [
{
src: "/screenshots/00-transcription.png",
alt: "Transcription alt",
title: "Transcription in action",
description: "Dictate into apps",
width: 100,
height: 80,
},
{
src: "/screenshots/02-system-tray.png",
alt: "Tray alt",
title: "System tray",
description: "Tray states",
width: 100,
height: 80,
},
];
const settingsShots: Screenshot[] = [
{
src: "/screenshots/settings-speech-engine.png",
alt: "Speech engine alt",
title: "Speech Engine",
description: "Pick a model",
width: 100,
height: 160,
},
];
describe("ScreenshotGallery lightbox", () => {
it("opens an expanded gallery view when a screenshot is clicked", async () => {
const user = userEvent.setup();
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "View larger: Speech Engine" }),
);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute("aria-modal", "true");
// Settings shot is third overall (index 2 -> 3/3)
expect(within(dialog).getByText(/3 \/ 3/)).toBeInTheDocument();
expect(within(dialog).getByAltText("Speech engine alt")).toBeInTheDocument();
expect(within(dialog).getByText("Speech Engine")).toBeInTheDocument();
});
it("navigates with next/prev controls and closes with Escape", async () => {
const user = userEvent.setup();
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
await user.click(
screen.getByRole("button", {
name: "View larger: Transcription in action",
}),
);
let dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(/1 \/ 3/)).toBeInTheDocument();
expect(within(dialog).getByAltText("Transcription alt")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next screenshot" }));
dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(/2 \/ 3/)).toBeInTheDocument();
expect(within(dialog).getByAltText("Tray alt")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Previous screenshot" }),
);
dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(/1 \/ 3/)).toBeInTheDocument();
expect(within(dialog).getByAltText("Transcription alt")).toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("supports keyboard arrow navigation while open", async () => {
const user = userEvent.setup();
render(
<ScreenshotGallery
productShots={productShots}
settingsShots={settingsShots}
/>,
);
await user.click(
screen.getByRole("button", {
name: "View larger: Transcription in action",
}),
);
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(
within(screen.getByRole("dialog")).getByAltText("Tray alt"),
).toBeInTheDocument();
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(
within(screen.getByRole("dialog")).getByAltText("Speech engine alt"),
).toBeInTheDocument();
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(
within(screen.getByRole("dialog")).getByAltText("Tray alt"),
).toBeInTheDocument();
});
});
+265
View File
@@ -0,0 +1,265 @@
"use client";
import React, { useCallback, useEffect, useId, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Expand,
ImageIcon,
Settings,
X,
} from "lucide-react";
export type Screenshot = {
src: string;
alt: string;
title: string;
description: string;
width: number;
height: number;
};
type ScreenshotGalleryProps = {
productShots: Screenshot[];
settingsShots: Screenshot[];
};
function ScreenshotCard({
shot,
onOpen,
}: {
shot: Screenshot;
onOpen: () => void;
}) {
return (
<figure className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<button
type="button"
onClick={onOpen}
className="group relative block w-full cursor-zoom-in bg-zinc-50 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 dark:bg-zinc-900/50"
aria-label={`View larger: ${shot.title}`}
>
{/* Plain img avoids flaky next/image HMR chunk errors in dev */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={shot.alt}
width={shot.width}
height={shot.height}
className="mx-auto h-auto w-full rounded-lg object-contain transition-transform duration-200 group-hover:scale-[1.02]"
loading="lazy"
decoding="async"
/>
<span className="pointer-events-none absolute bottom-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-zinc-900/80 px-2.5 py-1 text-xs font-medium text-white opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 dark:bg-zinc-100/90 dark:text-zinc-900">
<Expand className="h-3.5 w-3.5" />
Expand
</span>
</button>
<figcaption className="space-y-1 border-t border-zinc-200 p-4 dark:border-zinc-700">
<h3 className="text-lg font-semibold">{shot.title}</h3>
<p className="text-sm text-muted-foreground">{shot.description}</p>
</figcaption>
</figure>
);
}
function Lightbox({
shots,
index,
onClose,
onPrev,
onNext,
titleId,
}: {
shots: Screenshot[];
index: number;
onClose: () => void;
onPrev: () => void;
onNext: () => void;
titleId: string;
}) {
const shot = shots[index];
const hasPrev = index > 0;
const hasNext = index < shots.length - 1;
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
} else if (event.key === "ArrowLeft") {
onPrev();
} else if (event.key === "ArrowRight") {
onNext();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [onClose, onNext, onPrev]);
if (!shot) {
return null;
}
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 p-3 sm:p-6"
onClick={onClose}
>
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
aria-label="Close gallery"
>
<X className="h-5 w-5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onPrev();
}}
disabled={!hasPrev}
className="absolute left-2 top-1/2 z-10 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:pointer-events-none disabled:opacity-30 sm:left-4"
aria-label="Previous screenshot"
>
<ChevronLeft className="h-6 w-6" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNext();
}}
disabled={!hasNext}
className="absolute right-2 top-1/2 z-10 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:pointer-events-none disabled:opacity-30 sm:right-4"
aria-label="Next screenshot"
>
<ChevronRight className="h-6 w-6" />
</button>
<div
className="flex max-h-full w-full max-w-6xl flex-col items-center gap-3"
onClick={(event) => event.stopPropagation()}
>
<div className="relative flex max-h-[min(80vh,900px)] w-full items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- full-res lightbox; avoids Next Image constraints in overlay */}
<img
src={shot.src}
alt={shot.alt}
className="max-h-[min(80vh,900px)] w-auto max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
<div className="max-w-2xl px-2 text-center text-white">
<p id={titleId} className="text-lg font-semibold">
{shot.title}
</p>
<p className="mt-1 text-sm text-zinc-300">{shot.description}</p>
<p className="mt-2 text-xs text-zinc-400">
{index + 1} / {shots.length}
<span className="mx-2">·</span>
Esc to close · arrow keys to browse
</p>
</div>
</div>
</div>
);
}
export function ScreenshotGallery({
productShots,
settingsShots,
}: ScreenshotGalleryProps) {
const allShots = [...productShots, ...settingsShots];
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const titleId = useId();
const openAt = useCallback((index: number) => {
setActiveIndex(index);
}, []);
const close = useCallback(() => {
setActiveIndex(null);
}, []);
const showPrev = useCallback(() => {
setActiveIndex((current) => {
if (current === null || current <= 0) {
return current;
}
return current - 1;
});
}, []);
const showNext = useCallback(() => {
setActiveIndex((current) => {
if (current === null || current >= allShots.length - 1) {
return current;
}
return current + 1;
});
}, [allShots.length]);
return (
<>
<section className="mb-14">
<div className="mb-6 flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-primary" />
<h2 className="text-2xl font-bold">Product</h2>
</div>
<div className="grid gap-6 sm:grid-cols-2">
{productShots.map((shot, index) => (
<ScreenshotCard
key={shot.src}
shot={shot}
onOpen={() => openAt(index)}
/>
))}
</div>
</section>
<section className="mb-14">
<div className="mb-6 flex items-center gap-2">
<Settings className="h-5 w-5 text-primary" />
<h2 className="text-2xl font-bold">Settings</h2>
</div>
<p className="mb-6 max-w-3xl text-muted-foreground">
Every major settings tab, from speech engine selection through
advanced whisper.cpp tuning. Click any shot to expand it.
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{settingsShots.map((shot, index) => (
<ScreenshotCard
key={shot.src}
shot={shot}
onOpen={() => openAt(productShots.length + index)}
/>
))}
</div>
</section>
{activeIndex !== null && (
<Lightbox
shots={allShots}
index={activeIndex}
onClose={close}
onPrev={showPrev}
onNext={showNext}
titleId={titleId}
/>
)}
</>
);
}
+3
View File
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import {
BookOpen,
Activity,
Camera,
ChevronDown,
ChevronRight,
Code2,
@@ -39,6 +40,7 @@ const navCategories = [
items: [
{ href: "/", label: "Home", icon: Home },
{ href: "/install/", label: "Install Guides", icon: BookOpen },
{ href: "/screenshots/", label: "Screenshots", icon: Camera },
{ href: "/changelog/", label: "Changelog", icon: Zap },
],
},
@@ -211,6 +213,7 @@ export function SeoSubpageShell({ children }: SeoSubpageShellProps) {
items: [
{ href: "/", label: "Home", icon: Home },
{ href: "/install/", label: "Install", icon: BookOpen },
{ href: "/screenshots/", label: "Screenshots", icon: Camera },
{ href: "/changelog/", label: "Changelog", icon: Zap },
],
},