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.
@@ -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 & 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 864 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||