web: restyle vocalinux.com to the Voca family workbench (#728)
* web: restyle vocalinux.com to the Voca family workbench Bring the Linux marketing site onto the same paper, ink, and teal language as VocaHQ, VocaMac, VocaPhone, and VocaGateway. Drop the site-wide dark mode so the canvas stays paper-only, matching the other product sites. * web: loop the home ribbon without a gap or jump The ticker used a stretched full-width track, so a -50% slide left an empty right edge before snapping back. Two equal groups, each wider than the viewport, scroll by exactly one group width. * web: match vocahq.com ribbon speed and teal band The looped ticker was covering six item cycles in 32s, so it raced. It now spends 28s per cycle like vocahq.com, uses the same dark-teal band, and pauses when the tab is hidden. * web: drive the hero panel clock from the visitor's local time Replace the static 11:24 mock with a 24-hour HH:MM clock in the client timezone, updated on the minute. * web: add official platform marks on the family cards Use the .github brand platform SVGs (Linux, Apple, Android, Windows) plus the HQ server mark for VocaGateway, matching vocahq.com's product row. * web: restore a Light/Dark flip on the screenshots page The captured GTK shots exist in both appearances. A page-local switch defaults to prefers-color-scheme and stores a tab override. The rest of the site stays paper-only. * web: make the homepage CTA say Install, not Get The button already jumps to the one-line installer. Name that action, note that there is no account, and keep the same copy in the closer and the mobile menu. * web: style install commands as GTK terminals Command boxes use a headerbar with copy, a centered title, and window controls on the right. The prompt is user@linux:~$, not Mac traffic-light chrome. * web: stop repeating the engine list in the hero The proof line, the notes window, and the tray terminal all said the same whisper.cpp / Whisper / VOSK / Remote API line. Engines stay on the window chips. Proof is now desktop and license facts. The terminal shows launching into the tray. * web: reflow copy, color prompts, and add install paths Drop pretty/balance wrapping so titles and ledes follow the shell width. Color the user@linux prompt. Hero terminal now says audio stays on this machine. Ribbon adds project words. AppImage and source-build sit under the install command. * web: put X, Discord, and GitHub by the footer mark Official family social marks sit next to the Vocalinux logo. Footer columns are Product, Install, Guides, and Project. The bottom line is Part of VocaHQ, without X11 or whisper.cpp. * web: add hello@vocahq.com to the footer social row The official mail mark sits with X, Discord, and GitHub and opens the family hello@vocahq.com address. * web: use the circular mark in the header and footer Header and footer now use the official teal circle lockup instead of the square app icon. * web: ship a circular favicon and a family OG card Favicons use the teal circle lockup. The Open Graph image is redone in the paper workbench language with the current homepage line. * web: set the hero to Dictate locally / In any app Shorter couplet in the VocaHQ display size, with a matching lede and Open Graph card. * web: gray the hero terminal note as a comment The second line was reading as part of the vocalinux command. It is now a dim # comment under the prompt.
This commit is contained in:
@@ -62,24 +62,17 @@ describe('Lazy Loading Behavior', () => {
|
||||
});
|
||||
|
||||
describe('VocalinuxLogo Component', () => {
|
||||
it('lazy loads logo by default', () => {
|
||||
it('lazy loads the circular mark by default', () => {
|
||||
render(<VocalinuxLogo />);
|
||||
|
||||
expect(mockImage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priority: false,
|
||||
})
|
||||
);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg');
|
||||
expect(img).toHaveAttribute('loading', 'lazy');
|
||||
});
|
||||
|
||||
it('eager loads logo when priority is set', () => {
|
||||
it('eager loads the mark when priority is set', () => {
|
||||
render(<VocalinuxLogo priority />);
|
||||
|
||||
expect(mockImage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priority: true,
|
||||
})
|
||||
);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('loading', 'eager');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,14 +86,7 @@ describe('Lazy Loading Behavior', () => {
|
||||
});
|
||||
|
||||
it('prevents layout shift with explicit dimensions', () => {
|
||||
const { container } = render(
|
||||
<VocalinuxLogo width={64} height={64} />
|
||||
);
|
||||
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toBeInTheDocument();
|
||||
|
||||
// Picture element helps with responsive images and prevents layout shift
|
||||
render(<VocalinuxLogo width={64} height={64} />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('width', '64');
|
||||
expect(img).toHaveAttribute('height', '64');
|
||||
@@ -119,20 +105,10 @@ describe('Lazy Loading Behavior', () => {
|
||||
expect(source).toHaveAttribute('type', 'image/webp');
|
||||
});
|
||||
|
||||
it('browser selects WebP source before loading image', () => {
|
||||
const { container } = render(
|
||||
<VocalinuxLogo width={32} height={32} />
|
||||
);
|
||||
|
||||
const picture = container.querySelector('picture');
|
||||
const source = picture?.querySelector('source');
|
||||
const img = picture?.querySelector('img');
|
||||
|
||||
// picture > source should come before img in DOM order
|
||||
if (source && img) {
|
||||
const children = Array.from(picture?.children || []);
|
||||
expect(children.indexOf(source)).toBeLessThan(children.indexOf(img));
|
||||
}
|
||||
it('ships the circular mark as an SVG', () => {
|
||||
render(<VocalinuxLogo width={32} height={32} />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,6 @@ describe("Screenshots page and assets", () => {
|
||||
expect(gallerySource).toContain('role="dialog"');
|
||||
expect(gallerySource).toContain("aria-modal");
|
||||
expect(gallerySource).toContain("View larger");
|
||||
expect(gallerySource).toContain("ThemeScreenshotImg");
|
||||
});
|
||||
|
||||
it("references every public screenshot asset from the page source", () => {
|
||||
@@ -74,10 +73,8 @@ describe("Screenshots page and assets", () => {
|
||||
|
||||
it("links to the gallery and only embeds a few product shots on home", () => {
|
||||
// Craft home shows a handful of real app shots, then /screenshots/ for the rest.
|
||||
expect(homeSource).toContain('href: "/screenshots/"');
|
||||
expect(homeSource).toContain('href="/screenshots/"');
|
||||
expect(homeSource).toContain("/screenshots/00-transcription.png");
|
||||
expect(homeSource).toContain("/screenshots/dark/00-transcription.png");
|
||||
expect(homeSource).not.toContain("/screenshots/05-about-view.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-recognition.png");
|
||||
expect(homeSource).not.toContain("/screenshots/settings-audio.png");
|
||||
@@ -85,9 +82,14 @@ describe("Screenshots page and assets", () => {
|
||||
expect(homeSource).not.toContain("/screenshots/settings-advanced.png");
|
||||
});
|
||||
|
||||
it("exposes Screenshots in the shared subpage shell nav", () => {
|
||||
expect(shellSource).toContain('href: "/screenshots/"');
|
||||
expect(shellSource).toContain('label: "Screenshots"');
|
||||
it("exposes Screenshots in the shared site nav", () => {
|
||||
const navSource = fs.readFileSync(
|
||||
path.join(webRoot, "src/lib/site-nav.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(navSource).toContain('href: "/screenshots/"');
|
||||
expect(navSource).toContain('label: "Screenshots"');
|
||||
expect(shellSource).toContain("SiteChrome");
|
||||
});
|
||||
|
||||
it("includes captioned settings and product sections", () => {
|
||||
|
||||
@@ -15,10 +15,19 @@ import path from "node:path";
|
||||
|
||||
const root = path.resolve(__dirname, "../../..");
|
||||
const pageTsx = fs.readFileSync(path.join(root, "src/app/page.tsx"), "utf8");
|
||||
const terminalTsx = fs.readFileSync(
|
||||
path.join(root, "src/components/terminal-block.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const chromeTsx = fs.readFileSync(
|
||||
path.join(root, "src/components/site-chrome.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const globalsCss = fs.readFileSync(
|
||||
path.join(root, "src/styles/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
const homeSource = `${pageTsx}\n${terminalTsx}\n${chromeTsx}`;
|
||||
|
||||
const CHROME =
|
||||
process.env.CHROME_PATH ||
|
||||
@@ -42,9 +51,9 @@ describe("marketing home layout guards (shipped source)", () => {
|
||||
expect(globalsCss).toMatch(/overflow-x:\s*clip/);
|
||||
expect(globalsCss).toMatch(/\.terminal-panel pre/);
|
||||
expect(globalsCss).toMatch(/overflow-wrap:\s*anywhere|break-all|word-break/);
|
||||
expect(pageTsx).toMatch(/break-all/);
|
||||
expect(pageTsx).toMatch(/min-w-0/);
|
||||
expect(pageTsx).toMatch(/overflow-x-clip|overflow-x-auto/);
|
||||
expect(homeSource).toMatch(/break-all/);
|
||||
expect(homeSource).toMatch(/min-w-0/);
|
||||
expect(homeSource).toMatch(/overflow-x-clip|overflow-x-auto/);
|
||||
});
|
||||
|
||||
it("hero uses solid display type (no gradient text slop)", () => {
|
||||
@@ -53,6 +62,13 @@ describe("marketing home layout guards (shipped source)", () => {
|
||||
expect(pageTsx).toMatch(/font-display/);
|
||||
expect(pageTsx).toMatch(/TerminalBlock|terminal-panel/);
|
||||
});
|
||||
|
||||
it("uses the Voca family paper workbench tokens", () => {
|
||||
expect(globalsCss).toMatch(/--paper:\s*#f4f1e8/);
|
||||
expect(globalsCss).toMatch(/--brand:\s*#0f6b57/);
|
||||
expect(globalsCss).not.toMatch(/linear-gradient|radial-gradient|conic-gradient/);
|
||||
expect(pageTsx).not.toMatch(/linear-gradient|radial-gradient|conic-gradient/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("marketing home three-viewport overflow (live browser)", () => {
|
||||
@@ -127,11 +143,11 @@ describe("marketing home three-viewport overflow (live browser)", () => {
|
||||
|
||||
if (vp.width < 768) {
|
||||
await page.getByRole("button", { name: /toggle menu/i }).click();
|
||||
const featuresVisible = await page
|
||||
const howVisible = await page
|
||||
.locator("header")
|
||||
.getByRole("link", { name: "Features" })
|
||||
.getByRole("link", { name: "How it works" })
|
||||
.isVisible();
|
||||
expect(featuresVisible).toBe(true);
|
||||
expect(howVisible).toBe(true);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function AlternativesPage() {
|
||||
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
|
||||
) : (
|
||||
<XCircle
|
||||
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
|
||||
className="h-5 w-5 text-[#c45c5c]"
|
||||
aria-label="No"
|
||||
/>
|
||||
)}
|
||||
@@ -167,7 +167,7 @@ export default function AlternativesPage() {
|
||||
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
|
||||
) : (
|
||||
<XCircle
|
||||
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
|
||||
className="h-5 w-5 text-[#c45c5c]"
|
||||
aria-label="No"
|
||||
/>
|
||||
)}
|
||||
@@ -177,7 +177,7 @@ export default function AlternativesPage() {
|
||||
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
|
||||
) : (
|
||||
<XCircle
|
||||
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
|
||||
className="h-5 w-5 text-[#c45c5c]"
|
||||
aria-label="No"
|
||||
/>
|
||||
)}
|
||||
@@ -187,7 +187,7 @@ export default function AlternativesPage() {
|
||||
<CheckCircle2 className="h-5 w-5 text-primary" aria-label="Yes" />
|
||||
) : (
|
||||
<XCircle
|
||||
className="h-5 w-5 text-[#c45c5c] dark:text-[#e08b8b]"
|
||||
className="h-5 w-5 text-[#c45c5c]"
|
||||
aria-label="No"
|
||||
/>
|
||||
)}
|
||||
@@ -232,7 +232,7 @@ export default function AlternativesPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-[12px] border border-border bg-muted/40 p-4">
|
||||
<p className="mb-2 text-sm font-semibold text-[#c45c5c] dark:text-[#e08b8b]">
|
||||
<p className="mb-2 text-sm font-semibold text-[#c45c5c]">
|
||||
Cons
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -241,7 +241,7 @@ export default function AlternativesPage() {
|
||||
key={con}
|
||||
className="flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#c45c5c] dark:text-[#e08b8b]" />
|
||||
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#c45c5c]" />
|
||||
{con}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function AutostartPage() {
|
||||
<td className="py-3 pr-4 text-muted-foreground">{de.autostartMethod}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{de.supported ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Yes
|
||||
</span>
|
||||
|
||||
@@ -312,24 +312,24 @@ const getTypeStyles = (type: string) => {
|
||||
case "stable":
|
||||
return {
|
||||
badge:
|
||||
"bg-primary/10 text-primary dark:text-[color:var(--terminal-fg)]",
|
||||
"bg-primary/10 text-primary",
|
||||
label: "Stable",
|
||||
};
|
||||
case "beta":
|
||||
return {
|
||||
badge:
|
||||
"bg-primary/10 text-primary dark:text-primary",
|
||||
"bg-primary/10 text-primary",
|
||||
label: "Beta",
|
||||
};
|
||||
case "alpha":
|
||||
return {
|
||||
badge:
|
||||
"bg-muted text-muted-foreground dark:text-muted-foreground",
|
||||
"bg-muted text-muted-foreground",
|
||||
label: "Alpha",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
badge: "bg-muted text-zinc-700",
|
||||
badge: "bg-muted text-muted-foreground",
|
||||
label: type,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ export default async function DistroInstallPage({
|
||||
className={`rounded-[12px] border border-border p-6 ${styles.panelClass}`}
|
||||
>
|
||||
<h2 className="mb-3 inline-flex items-center gap-2 text-xl font-semibold">
|
||||
<Terminal className="h-5 w-5 text-primary dark:text-[color:var(--terminal-fg)]" />
|
||||
<Terminal className="h-5 w-5 text-primary" />
|
||||
Install command
|
||||
</h2>
|
||||
<div className="rounded-[12px] bg-[color:var(--terminal)] p-4">
|
||||
|
||||
@@ -2,10 +2,15 @@ import Link from "next/link";
|
||||
import { type Metadata } from "next";
|
||||
import { ChevronRight, Cpu, Laptop, Terminal } from "lucide-react";
|
||||
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
|
||||
import { TerminalBlock } from "@/components/terminal-block";
|
||||
import { buildPageMetadata } from "@/lib/seo";
|
||||
|
||||
const installCommand =
|
||||
"curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh --interactive";
|
||||
const installDisplayCommand = `curl -fsSL \\
|
||||
https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh \\
|
||||
-o /tmp/vl.sh && \\
|
||||
bash /tmp/vl.sh --interactive`;
|
||||
|
||||
const distroGuides = [
|
||||
{
|
||||
@@ -56,26 +61,42 @@ export default function InstallGuidesPage() {
|
||||
speech-to-text on Linux. Every guide is written for real desktop usage.
|
||||
</p>
|
||||
|
||||
<div className="subpage-terminal mt-8 p-5 sm:p-6">
|
||||
<p className="mb-3 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-wider text-zinc-500">
|
||||
<Terminal className="h-3.5 w-3.5 text-[color:var(--terminal-fg)]" />
|
||||
install.sh --interactive
|
||||
</p>
|
||||
<code className="block overflow-x-auto break-all text-sm sm:text-base">
|
||||
{installCommand}
|
||||
</code>
|
||||
<div className="mt-8">
|
||||
<TerminalBlock
|
||||
command={installCommand}
|
||||
displayCommand={installDisplayCommand}
|
||||
label="install.sh --interactive"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Prefer no installer? Download an{" "}
|
||||
<a
|
||||
href="https://github.com/VocaHQ/vocalinux/releases"
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
AppImage
|
||||
</a>{" "}
|
||||
for x86_64 or aarch64 from GitHub Releases (host text-injection tools
|
||||
still required).
|
||||
Then launch <code>vocalinux</code>.
|
||||
</p>
|
||||
<div className="install-alts">
|
||||
<a
|
||||
className="install-alt"
|
||||
href="https://github.com/VocaHQ/vocalinux/releases"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<p className="install-alt-kicker">No installer</p>
|
||||
<h3>AppImage</h3>
|
||||
<p>
|
||||
x86_64 or aarch64 from GitHub Releases. No root. Host
|
||||
text-injection tools are still required.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
className="install-alt"
|
||||
href="https://github.com/VocaHQ/vocalinux/blob/main/docs/INSTALL.md#from-source"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<p className="install-alt-kicker">From source</p>
|
||||
<h3>Build it yourself</h3>
|
||||
<p>
|
||||
Clone the repo and run <code>./install.sh</code>. The install
|
||||
guide has the full source path.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12 grid gap-4 md:grid-cols-3">
|
||||
|
||||
+7
-30
@@ -1,9 +1,7 @@
|
||||
import "@/styles/globals.css";
|
||||
import React from "react";
|
||||
import { Bricolage_Grotesque, Source_Sans_3 } from "next/font/google";
|
||||
import { type Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import {
|
||||
absoluteUrl,
|
||||
DEFAULT_OG_IMAGE_ALT,
|
||||
@@ -15,26 +13,11 @@ import {
|
||||
VOCAHQ_URL,
|
||||
} from "@/lib/seo";
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const body = Source_Sans_3({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-body",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0c0c0e" },
|
||||
],
|
||||
themeColor: "#f4f1e8",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -69,15 +52,16 @@ export const metadata: Metadata = {
|
||||
publisher: SITE_NAME,
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/vocalinux.png", type: "image/png" },
|
||||
{ url: "/brand/vocalinux-mark-circle.svg", type: "image/svg+xml" },
|
||||
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
{ url: "/vocalinux.png", type: "image/png", sizes: "512x512" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
||||
],
|
||||
shortcut: "/vocalinux.png",
|
||||
shortcut: "/favicon-32x32.png",
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
appleWebApp: {
|
||||
@@ -174,8 +158,7 @@ const webSiteJsonLd = {
|
||||
"@type": "SearchAction",
|
||||
target: {
|
||||
"@type": "EntryPoint",
|
||||
urlTemplate:
|
||||
`${GITHUB_REPO_URL}/issues?q={search_term_string}`,
|
||||
urlTemplate: `${GITHUB_REPO_URL}/issues?q={search_term_string}`,
|
||||
},
|
||||
"query-input": {
|
||||
"@type": "PropertyValueSpecification",
|
||||
@@ -193,11 +176,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${display.variable} ${body.variable} scroll-smooth`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://api.github.com" />
|
||||
<script
|
||||
@@ -218,9 +197,7 @@ export default function RootLayout({
|
||||
gtag('config', 'G-7NBBNJNNQ7');
|
||||
`}
|
||||
</Script>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -264,13 +264,13 @@ export default function OfflinePage() {
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
Run with <code className="rounded bg-muted px-1 dark:bg-muted">--debug</code> and
|
||||
Run with <code className="rounded bg-muted px-1">--debug</code> and
|
||||
watch network activity. Local engines should stay quiet during dictation.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
Monitor with <code className="rounded bg-muted px-1 dark:bg-muted">tcpdump</code> or{" "}
|
||||
<code className="rounded bg-muted px-1 dark:bg-muted">wireshark</code>. Local engines
|
||||
Monitor with <code className="rounded bg-muted px-1">tcpdump</code> or{" "}
|
||||
<code className="rounded bg-muted px-1">wireshark</code>. Local engines
|
||||
should stay quiet during dictation.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function OpenSourcePage() {
|
||||
{vsProprietary.map((row) => (
|
||||
<tr key={row.feature} className="border-b border-border">
|
||||
<td className="py-3 pr-4 font-medium">{row.feature}</td>
|
||||
<td className="py-3 pr-4 text-primary dark:text-[color:var(--terminal-fg)]">{row.vocalinux}</td>
|
||||
<td className="py-3 pr-4 text-primary">{row.vocalinux}</td>
|
||||
<td className="py-3 text-muted-foreground">{row.proprietary}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
+714
-962
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@ export default function PrivacyPage() {
|
||||
/>
|
||||
|
||||
<section>
|
||||
<p className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<p className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary">
|
||||
<Shield className="h-4 w-4" />
|
||||
Privacy First
|
||||
</p>
|
||||
@@ -65,8 +65,8 @@ export default function PrivacyPage() {
|
||||
{/* Key Privacy Points */}
|
||||
<section className="mb-12 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<article className="rounded-[12px] border border-border bg-background p-6">
|
||||
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3 dark:bg-primary/10">
|
||||
<Lock className="h-6 w-6 text-primary dark:text-[color:var(--terminal-fg)]" />
|
||||
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold">Local engines</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -76,7 +76,7 @@ export default function PrivacyPage() {
|
||||
</article>
|
||||
|
||||
<article className="rounded-[12px] border border-border bg-background p-6">
|
||||
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3 dark:bg-primary/10">
|
||||
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
|
||||
<Server className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold">No Cloud Processing</h3>
|
||||
@@ -87,7 +87,7 @@ export default function PrivacyPage() {
|
||||
</article>
|
||||
|
||||
<article className="rounded-[12px] border border-border bg-background p-6">
|
||||
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3 dark:bg-primary/10">
|
||||
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
|
||||
<Eye className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold">Open Source</h3>
|
||||
@@ -160,7 +160,7 @@ export default function PrivacyPage() {
|
||||
discarded immediately after processing
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-4 rounded-lg bg-muted p-4 text-sm dark:bg-muted">
|
||||
<p className="mt-4 rounded-lg bg-muted p-4 text-sm">
|
||||
<strong>Note:</strong> Local engines do not write audio to disk or send it over the
|
||||
network. Remote API sends audio to the server you configure.
|
||||
</p>
|
||||
@@ -213,31 +213,31 @@ export default function PrivacyPage() {
|
||||
<tbody className="text-sm text-muted-foreground">
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 font-medium text-foreground">Data leaves device</td>
|
||||
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<td className="py-3 text-primary">
|
||||
Only with Remote API
|
||||
</td>
|
||||
<td className="py-3 text-primary">Always</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 font-medium text-foreground">Internet required</td>
|
||||
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<td className="py-3 text-primary">
|
||||
Only for Remote API or a model download
|
||||
</td>
|
||||
<td className="py-3 text-primary">Yes</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 font-medium text-foreground">Source code available</td>
|
||||
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">Yes (AGPL-3.0)</td>
|
||||
<td className="py-3 text-primary">Yes (AGPL-3.0)</td>
|
||||
<td className="py-3 text-primary">No</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 font-medium text-foreground">Voice recordings stored</td>
|
||||
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">No</td>
|
||||
<td className="py-3 text-primary">No</td>
|
||||
<td className="py-3">Varies by service</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 font-medium text-foreground">Cost</td>
|
||||
<td className="py-3 text-primary dark:text-[color:var(--terminal-fg)]">Free forever</td>
|
||||
<td className="py-3 text-primary">Free forever</td>
|
||||
<td className="py-3">Subscription</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -196,8 +196,8 @@ export default function RsiPreventionPage() {
|
||||
>
|
||||
<h3 className="mb-2 font-semibold">{condition.name}</h3>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{condition.description}</p>
|
||||
<div className="rounded-lg bg-primary/10 p-3 dark:bg-primary/10">
|
||||
<p className="text-sm text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<div className="rounded-lg bg-primary/10 p-3">
|
||||
<p className="text-sm text-primary">
|
||||
<strong>How dictation helps:</strong> {condition.howDictationHelps}
|
||||
</p>
|
||||
</div>
|
||||
@@ -254,7 +254,7 @@ export default function RsiPreventionPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[12px] border border-border bg-primary/10 p-8 dark:border-border">
|
||||
<section className="rounded-[12px] border border-border bg-primary/10 p-8">
|
||||
<h2 className="mb-4 font-display text-2xl font-semibold text-primary">
|
||||
Take Care of Your Hands
|
||||
</h2>
|
||||
@@ -273,7 +273,7 @@ export default function RsiPreventionPage() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/use-cases/"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/10"
|
||||
>
|
||||
See Other Use Cases
|
||||
</Link>
|
||||
|
||||
@@ -105,7 +105,7 @@ const settingsShots: Screenshot[] = [
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
title: "Vocalinux Screenshots | Linux Voice Dictation UI",
|
||||
description:
|
||||
"See Vocalinux in action: system tray, log viewer, and settings pages for speech model, dictation, audio, performance, application, advanced options, and about/updates. Light and dark themes included.",
|
||||
"See Vocalinux in action: system tray, log viewer, and settings pages for speech model, dictation, audio, performance, application, advanced options, and about/updates. Flip between light and dark app shots.",
|
||||
path: "/screenshots",
|
||||
keywords: [
|
||||
"Vocalinux screenshots",
|
||||
@@ -157,9 +157,8 @@ export default function ScreenshotsPage() {
|
||||
</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 searchable sidebar settings UI from v0.15. Toggle the
|
||||
site theme to switch between light and dark app shots. Click any image
|
||||
to expand it and browse the gallery.
|
||||
tools, and the searchable sidebar settings UI from v0.15. Flip light
|
||||
and dark app shots. Click any image to expand it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function TroubleshootingPage() {
|
||||
>
|
||||
<h2 className="mb-4 text-xl font-semibold">{item.title}</h2>
|
||||
|
||||
<div className="mb-4 rounded-lg bg-muted p-4 dark:bg-muted">
|
||||
<div className="mb-4 rounded-lg bg-muted p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Symptoms
|
||||
@@ -260,8 +260,8 @@ export default function TroubleshootingPage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-primary/10 p-4 dark:bg-primary/10">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<div className="rounded-lg bg-primary/10 p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Solutions
|
||||
</p>
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function VoiceTypingVsCodePage() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-12 rounded-[12px] border border-border bg-muted p-6 dark:border-border">
|
||||
<section className="mb-12 rounded-[12px] border border-border bg-muted p-6">
|
||||
<h2 className="mb-4 flex items-center gap-2 font-display text-2xl font-semibold text-muted-foreground">
|
||||
<Bug className="h-5 w-5" />
|
||||
Troubleshooting
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function VsNerdDictationPage() {
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-muted-foreground">{row.vocalinux}</td>
|
||||
<td className="px-4 py-4 text-sm text-muted-foreground">{row.nerdDictation}</td>
|
||||
<td className="px-4 py-4 text-sm font-semibold text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<td className="px-4 py-4 text-sm font-semibold text-primary">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{row.winner}
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function WaylandPage() {
|
||||
{item.method}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary dark:text-[color:var(--terminal-fg)]">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{item.status}
|
||||
</span>
|
||||
|
||||
@@ -98,21 +98,10 @@ describe('VocalinuxLogo', () => {
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a picture element with WebP source for logo', () => {
|
||||
const { container } = render(<VocalinuxLogo />);
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toBeInTheDocument();
|
||||
|
||||
const source = container.querySelector('source');
|
||||
expect(source).toBeInTheDocument();
|
||||
expect(source).toHaveAttribute('srcset', '/vocalinux.webp');
|
||||
expect(source).toHaveAttribute('type', 'image/webp');
|
||||
});
|
||||
|
||||
it('provides PNG fallback for the logo', () => {
|
||||
it('uses the circular mark', () => {
|
||||
render(<VocalinuxLogo />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('src', '/vocalinux.png');
|
||||
expect(img).toHaveAttribute('src', '/brand/vocalinux-mark-circle.svg');
|
||||
});
|
||||
|
||||
it('uses default dimensions when not specified', () => {
|
||||
@@ -130,14 +119,14 @@ describe('VocalinuxLogo', () => {
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<VocalinuxLogo className="logo-class" />);
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toHaveClass('logo-class');
|
||||
render(<VocalinuxLogo className="logo-class" />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveClass('logo-class');
|
||||
});
|
||||
|
||||
it('passes priority prop for eager loading', () => {
|
||||
render(<VocalinuxLogo priority />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).not.toHaveAttribute('loading', 'lazy');
|
||||
expect(img).toHaveAttribute('loading', 'eager');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
ScreenshotGallery,
|
||||
@@ -39,7 +39,145 @@ const settingsShots: Screenshot[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function mockColorScheme(dark: boolean) {
|
||||
const listeners = new Set<(event: MediaQueryListEvent) => void>();
|
||||
const media = {
|
||||
matches: dark,
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
onchange: null,
|
||||
addEventListener: (
|
||||
_type: string,
|
||||
listener: (event: MediaQueryListEvent) => void,
|
||||
) => {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (
|
||||
_type: string,
|
||||
listener: (event: MediaQueryListEvent) => void,
|
||||
) => {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
emit(next: boolean) {
|
||||
media.matches = next;
|
||||
const event = { matches: next } as MediaQueryListEvent;
|
||||
listeners.forEach((listener) => listener(event));
|
||||
},
|
||||
};
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => {
|
||||
if (query.includes("prefers-color-scheme: dark")) return media;
|
||||
return { ...media, matches: false, addEventListener: jest.fn() };
|
||||
});
|
||||
return media;
|
||||
}
|
||||
|
||||
describe("ScreenshotGallery lightbox", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
mockColorScheme(false);
|
||||
});
|
||||
|
||||
it("defaults to the browser color scheme before any override", async () => {
|
||||
mockColorScheme(true);
|
||||
render(
|
||||
<ScreenshotGallery
|
||||
productShots={productShots}
|
||||
settingsShots={settingsShots}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img.is-on[src="/screenshots/dark/00-transcription.png"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("follows the browser scheme until the visitor flips the switch", async () => {
|
||||
const media = mockColorScheme(true);
|
||||
render(
|
||||
<ScreenshotGallery
|
||||
productShots={productShots}
|
||||
settingsShots={settingsShots}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
media.emit(false);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Dark" }));
|
||||
act(() => {
|
||||
media.emit(false);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
expect(sessionStorage.getItem("vocalinux-shot-appearance")).toBe("dark");
|
||||
});
|
||||
|
||||
it("restores a tab override from sessionStorage", async () => {
|
||||
sessionStorage.setItem("vocalinux-shot-appearance", "dark");
|
||||
render(
|
||||
<ScreenshotGallery
|
||||
productShots={productShots}
|
||||
settingsShots={settingsShots}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("flips between light and dark app shots without changing the page theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ScreenshotGallery
|
||||
productShots={productShots}
|
||||
settingsShots={settingsShots}
|
||||
/>,
|
||||
);
|
||||
|
||||
const lightBtn = screen.getByRole("button", { name: "Light" });
|
||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||
expect(lightBtn).toHaveAttribute("aria-pressed", "true");
|
||||
expect(darkBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await user.click(darkBtn);
|
||||
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
|
||||
expect(lightBtn).toHaveAttribute("aria-pressed", "false");
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img.is-on[src="/screenshots/dark/settings-speech-engine.png"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(document.documentElement).not.toHaveClass("dark");
|
||||
});
|
||||
|
||||
it("opens an expanded gallery view when a screenshot is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -61,15 +199,11 @@ describe("ScreenshotGallery lightbox", () => {
|
||||
// Settings shot is third overall (index 2 -> 3/3)
|
||||
expect(within(dialog).getByText(/3 \/ 3/)).toBeInTheDocument();
|
||||
const speechImgs = within(dialog).getAllByAltText("Speech engine alt");
|
||||
expect(speechImgs.length).toBe(2);
|
||||
expect(speechImgs.length).toBe(1);
|
||||
expect(speechImgs[0]).toHaveAttribute(
|
||||
"src",
|
||||
"/screenshots/settings-speech-engine.png",
|
||||
);
|
||||
expect(speechImgs[1]).toHaveAttribute(
|
||||
"src",
|
||||
"/screenshots/dark/settings-speech-engine.png",
|
||||
);
|
||||
expect(within(dialog).getByText("Speech Engine")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ const SyntaxHighlighter = dynamic<SyntaxHighlighterProps>(
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="rounded-lg bg-zinc-900 p-4 text-sm">
|
||||
<pre className="whitespace-pre-wrap text-green-400">Loading...</pre>
|
||||
<div className="subpage-terminal p-4 text-sm">
|
||||
<pre className="whitespace-pre-wrap">Loading...</pre>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -57,7 +57,7 @@ export function CodeBlock({
|
||||
if (!style) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg bg-zinc-900 p-4 text-sm ${className}`}
|
||||
className={`subpage-terminal p-4 text-sm ${className}`}
|
||||
style={customStyle}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap text-green-400">{children}</pre>
|
||||
|
||||
@@ -55,16 +55,16 @@ export const VocalinuxLogo: React.FC<{
|
||||
priority?: boolean;
|
||||
}> = ({ className = "", width = 32, height = 32, priority = false }) => {
|
||||
return (
|
||||
<picture className={className}>
|
||||
<source srcSet="/vocalinux.webp" type="image/webp" />
|
||||
<Image
|
||||
src="/vocalinux.png"
|
||||
alt="Vocalinux"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
priority={priority}
|
||||
/>
|
||||
</picture>
|
||||
// SVG lockup; next/image is for raster.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src="/brand/vocalinux-mark-circle.svg"
|
||||
alt="Vocalinux"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
decoding="async"
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,14 +9,9 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
darkScreenshotPath,
|
||||
ThemeScreenshotImg,
|
||||
} from "@/components/theme-screenshot";
|
||||
|
||||
export type Screenshot = {
|
||||
src: string;
|
||||
/** Dark-theme variant; defaults to `/screenshots/dark/<file>`. */
|
||||
srcDark?: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
@@ -25,44 +20,109 @@ export type Screenshot = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ShotTheme = "light" | "dark";
|
||||
|
||||
const THEME_STORAGE_KEY = "vocalinux-shot-appearance";
|
||||
|
||||
export function darkScreenshotPath(src: string): string {
|
||||
if (src.includes("/dark/")) return src;
|
||||
return src.replace("/screenshots/", "/screenshots/dark/");
|
||||
}
|
||||
|
||||
function shotDarkSrc(shot: Screenshot): string {
|
||||
return shot.srcDark ?? darkScreenshotPath(shot.src);
|
||||
}
|
||||
|
||||
function systemShotTheme(): ShotTheme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function readStoredTheme(): ShotTheme | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type ScreenshotGalleryProps = {
|
||||
productShots: Screenshot[];
|
||||
settingsShots: Screenshot[];
|
||||
};
|
||||
|
||||
function ShotPair({
|
||||
shot,
|
||||
theme,
|
||||
className,
|
||||
loading = "lazy",
|
||||
}: {
|
||||
shot: Screenshot;
|
||||
theme: ShotTheme;
|
||||
className?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
}) {
|
||||
const darkSrc = shotDarkSrc(shot);
|
||||
return (
|
||||
<div className="shot-pair">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={shot.src}
|
||||
alt={theme === "light" ? shot.alt : ""}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
className={`${className ?? ""} ${theme === "light" ? "is-on" : ""}`.trim()}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
aria-hidden={theme !== "light"}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={darkSrc}
|
||||
alt={theme === "dark" ? shot.alt : ""}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
className={`${className ?? ""} ${theme === "dark" ? "is-on" : ""}`.trim()}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
aria-hidden={theme !== "dark"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenshotCard({
|
||||
shot,
|
||||
theme,
|
||||
onOpen,
|
||||
}: {
|
||||
shot: Screenshot;
|
||||
theme: ShotTheme;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<figure className="flex h-full flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<figure className="gallery-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="group relative flex min-h-0 w-full flex-1 cursor-zoom-in items-center justify-center 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"
|
||||
className="group relative flex min-h-0 w-full flex-1 cursor-zoom-in items-center justify-center bg-muted p-3 text-left"
|
||||
aria-label={`View larger: ${shot.title}`}
|
||||
>
|
||||
<ThemeScreenshotImg
|
||||
src={shot.src}
|
||||
srcDark={shotDarkSrc(shot)}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
className="mx-auto h-auto max-h-64 w-full rounded-lg object-contain transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
<ShotPair
|
||||
shot={shot}
|
||||
theme={theme}
|
||||
className="mx-auto h-auto max-h-64 w-full rounded-lg object-contain"
|
||||
/>
|
||||
<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">
|
||||
<span className="pointer-events-none absolute bottom-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-[color:var(--dark-ink)] px-2.5 py-1 text-xs font-medium text-[color:var(--paper-bright)] opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<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">
|
||||
<figcaption className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">{shot.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{shot.description}</p>
|
||||
</figcaption>
|
||||
@@ -73,6 +133,7 @@ function ScreenshotCard({
|
||||
function Lightbox({
|
||||
shots,
|
||||
index,
|
||||
theme,
|
||||
onClose,
|
||||
onPrev,
|
||||
onNext,
|
||||
@@ -80,6 +141,7 @@ function Lightbox({
|
||||
}: {
|
||||
shots: Screenshot[];
|
||||
index: number;
|
||||
theme: ShotTheme;
|
||||
onClose: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
@@ -162,22 +224,19 @@ function Lightbox({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex max-h-[min(80vh,900px)] w-full items-center justify-center">
|
||||
<ThemeScreenshotImg
|
||||
src={shot.src}
|
||||
srcDark={shotDarkSrc(shot)}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
className="max-h-[min(80vh,900px)] w-auto max-w-full rounded-lg object-contain shadow-2xl"
|
||||
<ShotPair
|
||||
shot={shot}
|
||||
theme={theme}
|
||||
loading="eager"
|
||||
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">
|
||||
<p className="mt-1 text-sm text-white/80">{shot.description}</p>
|
||||
<p className="mt-2 text-xs text-white/60">
|
||||
{index + 1} / {shots.length}
|
||||
<span className="mx-2">·</span>
|
||||
Esc to close · arrow keys to browse
|
||||
@@ -188,14 +247,85 @@ function Lightbox({
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSwitch({
|
||||
theme,
|
||||
onChange,
|
||||
}: {
|
||||
theme: ShotTheme;
|
||||
onChange: (theme: ShotTheme) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="shot-theme-bar">
|
||||
<div>
|
||||
<p className="shot-theme-kicker">App appearance</p>
|
||||
<p className="shot-theme-copy">
|
||||
Flip the captured GTK theme. Starts from your system setting.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="shot-switch"
|
||||
data-mode={theme}
|
||||
role="group"
|
||||
aria-label="App appearance"
|
||||
>
|
||||
<span className="shot-switch-knob" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={theme === "light"}
|
||||
onClick={() => onChange("light")}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={theme === "dark"}
|
||||
onClick={() => onChange("dark")}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenshotGallery({
|
||||
productShots,
|
||||
settingsShots,
|
||||
}: ScreenshotGalleryProps) {
|
||||
const allShots = [...productShots, ...settingsShots];
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [theme, setTheme] = useState<ShotTheme>("light");
|
||||
const [userOverride, setUserOverride] = useState(false);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredTheme();
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
setUserOverride(true);
|
||||
return;
|
||||
}
|
||||
setTheme(systemShotTheme());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userOverride) return undefined;
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => setTheme(media.matches ? "dark" : "light");
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, [userOverride]);
|
||||
|
||||
const chooseTheme = useCallback((next: ShotTheme) => {
|
||||
setTheme(next);
|
||||
setUserOverride(true);
|
||||
try {
|
||||
sessionStorage.setItem(THEME_STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openAt = useCallback((index: number) => {
|
||||
setActiveIndex(index);
|
||||
}, []);
|
||||
@@ -224,6 +354,8 @@ export function ScreenshotGallery({
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppearanceSwitch theme={theme} onChange={chooseTheme} />
|
||||
|
||||
<section className="mb-14">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<ImageIcon className="h-5 w-5 text-primary" />
|
||||
@@ -234,6 +366,7 @@ export function ScreenshotGallery({
|
||||
<ScreenshotCard
|
||||
key={shot.src}
|
||||
shot={shot}
|
||||
theme={theme}
|
||||
onOpen={() => openAt(index)}
|
||||
/>
|
||||
))}
|
||||
@@ -247,14 +380,14 @@ export function ScreenshotGallery({
|
||||
</div>
|
||||
<p className="mb-6 max-w-3xl text-muted-foreground">
|
||||
Every sidebar settings page, from speech model selection through
|
||||
advanced whisper.cpp tuning. Click any shot to expand it — images
|
||||
follow the site light/dark theme.
|
||||
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}
|
||||
theme={theme}
|
||||
onOpen={() => openAt(productShots.length + index)}
|
||||
/>
|
||||
))}
|
||||
@@ -265,6 +398,7 @@ export function ScreenshotGallery({
|
||||
<Lightbox
|
||||
shots={allShots}
|
||||
index={activeIndex}
|
||||
theme={theme}
|
||||
onClose={close}
|
||||
onPrev={showPrev}
|
||||
onNext={showNext}
|
||||
|
||||
@@ -1,195 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
BookOpen,
|
||||
Activity,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Cpu,
|
||||
Globe,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Menu,
|
||||
Monitor,
|
||||
Power,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Shield,
|
||||
X,
|
||||
Zap,
|
||||
Github,
|
||||
} from "lucide-react";
|
||||
import { VocalinuxLogo } from "@/components/optimized-image";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { SiteChrome } from "@/components/site-chrome";
|
||||
|
||||
interface SeoSubpageShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/install": "Install Guides",
|
||||
"/install/ubuntu": "Ubuntu",
|
||||
"/install/fedora": "Fedora",
|
||||
"/install/arch": "Arch",
|
||||
"/screenshots": "Screenshots",
|
||||
"/changelog": "Changelog",
|
||||
"/compare": "Engine Comparison",
|
||||
"/remote-api": "Remote API",
|
||||
"/languages": "Languages",
|
||||
"/wayland": "Wayland",
|
||||
"/gpu-acceleration": "GPU Acceleration",
|
||||
"/voice-activity-detection": "Silero VAD",
|
||||
"/advanced-settings": "Advanced Settings",
|
||||
"/desktop-reliability": "Desktop Reliability",
|
||||
"/autostart": "Autostart",
|
||||
"/for-developers": "For Developers",
|
||||
"/rsi-prevention": "RSI Prevention",
|
||||
"/writers": "For Writers",
|
||||
"/gnome-kde": "GNOME vs KDE",
|
||||
"/use-cases": "Use Cases",
|
||||
"/vs-nerd-dictation": "vs Nerd Dictation",
|
||||
"/whisper-model-guide": "Whisper Models",
|
||||
"/voice-typing-vscode": "VS Code",
|
||||
"/offline": "Offline",
|
||||
"/open-source": "Open Source",
|
||||
"/privacy": "Privacy Policy",
|
||||
"/faq": "FAQ",
|
||||
"/troubleshooting": "Troubleshooting",
|
||||
"/shortcuts": "Voice Commands",
|
||||
"/alternatives": "Alternatives",
|
||||
};
|
||||
|
||||
const navCategories = [
|
||||
{
|
||||
label: "Get Started",
|
||||
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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
items: [
|
||||
{ href: "/compare/", label: "Engine Comparison", icon: Cpu },
|
||||
{ href: "/remote-api/", label: "Remote API", icon: Server },
|
||||
{ href: "/languages/", label: "Multilingual", icon: Globe },
|
||||
{ href: "/wayland/", label: "Wayland Support", icon: Monitor },
|
||||
{ href: "/gpu-acceleration/", label: "GPU Acceleration", icon: Cpu },
|
||||
{
|
||||
href: "/voice-activity-detection/",
|
||||
label: "Silero VAD",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: "/advanced-settings/",
|
||||
label: "Advanced Settings",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
href: "/desktop-reliability/",
|
||||
label: "Desktop Reliability",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{ href: "/autostart/", label: "Autostart", icon: Power },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Use Cases",
|
||||
items: [
|
||||
{ href: "/for-developers/", label: "For Developers", icon: Code2 },
|
||||
{ href: "/rsi-prevention/", label: "RSI Prevention", icon: Heart },
|
||||
{ href: "/writers/", label: "For Writers", icon: Heart },
|
||||
{ href: "/gnome-kde/", label: "GNOME vs KDE", icon: Monitor },
|
||||
{ href: "/use-cases/", label: "All Use Cases", icon: Zap },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Comparisons",
|
||||
items: [
|
||||
{
|
||||
href: "/vs-nerd-dictation/",
|
||||
label: "vs Nerd Dictation",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{ href: "/whisper-model-guide/", label: "Whisper Models", icon: Cpu },
|
||||
{ href: "/voice-typing-vscode/", label: "VS Code", icon: Code2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Privacy & Ethics",
|
||||
items: [
|
||||
{ href: "/offline/", label: "Offline", icon: Shield },
|
||||
{ href: "/open-source/", label: "Open Source", icon: Code2 },
|
||||
{ href: "/privacy/", label: "Privacy Policy", icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
items: [
|
||||
{ href: "/faq/", label: "FAQ", icon: HelpCircle },
|
||||
{ href: "/troubleshooting/", label: "Troubleshooting", icon: Zap },
|
||||
{ href: "/shortcuts/", label: "Voice Commands", icon: BookOpen },
|
||||
{ href: "/alternatives/", label: "Alternatives", icon: BookOpen },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function DropdownMenu({ category }: { category: (typeof navCategories)[0] }) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:bg-muted focus:text-foreground focus:outline-none"
|
||||
>
|
||||
{category.label}
|
||||
<ChevronDown className="h-3.5 w-3.5 transition-transform group-focus-within:rotate-180 group-hover:rotate-180" />
|
||||
</button>
|
||||
|
||||
<div className="pointer-events-none invisible absolute left-0 top-full z-50 mt-0 w-56 rounded-[12px] border border-border bg-background p-1.5 opacity-0 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.35)] transition-all duration-150 group-focus-within:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100">
|
||||
{category.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function titleFromSegment(segment: string): string {
|
||||
return segment.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === "/") return null;
|
||||
if (!pathname || pathname === "/") return null;
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const breadcrumbs = segments.map((segment, index) => {
|
||||
const crumbs = segments.map((segment, index) => {
|
||||
const href = "/" + segments.slice(0, index + 1).join("/");
|
||||
const label = segment
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
return { href, label };
|
||||
const key = href.replace(/\/$/, "");
|
||||
return {
|
||||
href: href.endsWith("/") ? href : `${href}/`,
|
||||
label: pageTitles[key] ?? titleFromSegment(segment),
|
||||
};
|
||||
});
|
||||
|
||||
const getPageTitle = (path: string): string => {
|
||||
for (const category of navCategories) {
|
||||
for (const item of category.items) {
|
||||
if (item.href === path || item.href === path + "/") {
|
||||
return item.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
path
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase()) || ""
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="mb-8 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/" className="transition-colors hover:text-foreground">
|
||||
Home
|
||||
</Link>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<span className="font-medium text-foreground">
|
||||
{getPageTitle(crumb.href)}
|
||||
</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
{index === crumbs.length - 1 ? (
|
||||
<span>{crumb.label}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
<Link href={crumb.href}>{crumb.label}</Link>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -197,246 +73,15 @@ function Breadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SeoSubpageShell({ children }: SeoSubpageShellProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (window.innerWidth >= 1024) setMobileMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const mainNavCategories = [
|
||||
{
|
||||
label: "Get Started",
|
||||
items: [
|
||||
{ href: "/", label: "Home", icon: Home },
|
||||
{ href: "/install/", label: "Install", icon: BookOpen },
|
||||
{ href: "/screenshots/", label: "Screenshots", icon: Camera },
|
||||
{ href: "/changelog/", label: "Changelog", icon: Zap },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
items: [
|
||||
{ href: "/compare/", label: "Engine Comparison", icon: Cpu },
|
||||
{ href: "/remote-api/", label: "Remote API", icon: Server },
|
||||
{ href: "/whisper-model-guide/", label: "Whisper Models", icon: Cpu },
|
||||
{ href: "/wayland/", label: "Wayland", icon: Monitor },
|
||||
{ href: "/gpu-acceleration/", label: "GPU Acceleration", icon: Cpu },
|
||||
{
|
||||
href: "/voice-activity-detection/",
|
||||
label: "Silero VAD",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: "/advanced-settings/",
|
||||
label: "Advanced Settings",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
href: "/desktop-reliability/",
|
||||
label: "Reliability",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Use Cases",
|
||||
items: [
|
||||
{ href: "/for-developers/", label: "For Developers", icon: Code2 },
|
||||
{ href: "/writers/", label: "For Writers", icon: Heart },
|
||||
{ href: "/voice-typing-vscode/", label: "VS Code", icon: Code2 },
|
||||
{ href: "/gnome-kde/", label: "GNOME vs KDE", icon: Monitor },
|
||||
{ href: "/rsi-prevention/", label: "RSI Prevention", icon: Heart },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Compare",
|
||||
items: [
|
||||
{
|
||||
href: "/vs-nerd-dictation/",
|
||||
label: "vs Nerd Dictation",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{ href: "/alternatives/", label: "Alternatives", icon: BookOpen },
|
||||
{ href: "/offline/", label: "Offline", icon: Shield },
|
||||
{ href: "/open-source/", label: "Open Source", icon: Code2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
items: [
|
||||
{ href: "/faq/", label: "FAQ", icon: HelpCircle },
|
||||
{ href: "/troubleshooting/", label: "Troubleshooting", icon: Zap },
|
||||
{ href: "/shortcuts/", label: "Commands", icon: BookOpen },
|
||||
{ href: "/privacy/", label: "Privacy Policy", icon: Shield },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function SeoSubpageShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen max-w-[100vw] overflow-x-clip bg-background text-foreground">
|
||||
<header className="fixed inset-x-0 top-0 z-50 border-b border-border/80 bg-background/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:h-16 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<VocalinuxLogo width={28} height={28} className="h-7 w-7" />
|
||||
<span className="font-display text-[15px] font-semibold tracking-tight">
|
||||
Vocalinux
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{mainNavCategories.map((category) => (
|
||||
<DropdownMenu key={category.label} category={category} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/#install"
|
||||
className="hidden h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground sm:inline-flex"
|
||||
>
|
||||
Install
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
}}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
|
||||
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<SiteChrome variant="page">
|
||||
<div className="page-main">
|
||||
<div className="shell">
|
||||
<Breadcrumbs />
|
||||
<div className="subpage-content min-w-0">{children}</div>
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
<div className="border-t border-border bg-background lg:hidden">
|
||||
<nav className="max-h-[calc(100vh-4rem)] overflow-y-auto px-4 py-4">
|
||||
{mainNavCategories.map((category) => (
|
||||
<div key={category.label} className="mb-4">
|
||||
<h3 className="mb-2 font-mono text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{category.label}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{category.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
href="/#install"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="mt-2 flex items-center justify-center rounded-full bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground"
|
||||
>
|
||||
Install Vocalinux
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-16 pt-24 sm:px-6 sm:pt-28">
|
||||
<Breadcrumbs />
|
||||
<div className="subpage-content min-w-0">{children}</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border bg-[#0a0a0c] text-zinc-300">
|
||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||
<div className="mb-8 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">
|
||||
Want the full product overview?
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Homepage has the install command, app screenshots, and engine
|
||||
guide.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||
>
|
||||
Home
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/#install"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-500"
|
||||
>
|
||||
Install
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/VocaHQ/vocalinux"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-100 transition-colors hover:border-zinc-500"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 border-t border-zinc-800 pt-8 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{mainNavCategories.map((category) => (
|
||||
<div key={category.label}>
|
||||
<h4 className="mb-2 font-mono text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{category.label}
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{category.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-sm text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-8 border-t border-zinc-800 pt-6 text-sm text-zinc-500">
|
||||
Vocalinux is part of{" "}
|
||||
<a
|
||||
href="https://vocahq.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
VocaHQ
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</SiteChrome>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Star } from "lucide-react";
|
||||
import { VocalinuxLogo } from "@/components/optimized-image";
|
||||
import { GITHUB_REPO_URL, VOCAHQ_URL } from "@/lib/seo";
|
||||
import {
|
||||
footerGroups,
|
||||
homeNav,
|
||||
mobileExtraNav,
|
||||
pageNav,
|
||||
socialLinks,
|
||||
type NavLink,
|
||||
} from "@/lib/site-nav";
|
||||
|
||||
const GITHUB_API_REPO_URL = `https://api.github.com/repos/${GITHUB_REPO_URL.replace("https://github.com/", "")}`;
|
||||
|
||||
function SmartLink({
|
||||
href,
|
||||
className,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
href: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const external = href.startsWith("http");
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (href.startsWith("#")) {
|
||||
return (
|
||||
<a href={href} className={className} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={className} onClick={onClick}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteChrome({
|
||||
children,
|
||||
variant = "page",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: "home" | "page";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stars, setStars] = useState<number | null>(null);
|
||||
const navId = useId();
|
||||
const nav = variant === "home" ? homeNav : pageNav;
|
||||
const ctaHref = variant === "home" ? "#install" : "/#install";
|
||||
const extras: NavLink[] =
|
||||
variant === "home"
|
||||
? [
|
||||
{ href: "/screenshots/", label: "Screenshots" },
|
||||
{ href: "/install/", label: "Distro guides" },
|
||||
...mobileExtraNav,
|
||||
]
|
||||
: mobileExtraNav;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onResize = () => {
|
||||
if (window.innerWidth >= 921) setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(GITHUB_API_REPO_URL)
|
||||
.then((res) => res.json())
|
||||
.then((data: { stargazers_count?: number }) => {
|
||||
if (data.stargazers_count) setStars(data.stargazers_count);
|
||||
})
|
||||
.catch(() => setStars(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] max-w-[100vw] overflow-x-clip">
|
||||
<a className="skip-link" href="#main">
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<header className="site-header">
|
||||
<div className="shell header-inner">
|
||||
<Link href="/" className="brand" aria-label="Vocalinux home">
|
||||
<VocalinuxLogo width={28} height={28} className="h-7 w-7" priority />
|
||||
<span>Vocalinux</span>
|
||||
</Link>
|
||||
|
||||
<nav className="nav-links" aria-label="Primary">
|
||||
{nav.map((item) => (
|
||||
<SmartLink key={item.href + item.label} href={item.href}>
|
||||
{item.label}
|
||||
</SmartLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="header-actions">
|
||||
<a
|
||||
className="header-stars"
|
||||
href={GITHUB_REPO_URL}
|
||||
rel="noopener noreferrer"
|
||||
aria-label={
|
||||
stars !== null
|
||||
? `Vocalinux on GitHub, ${stars.toLocaleString()} stars`
|
||||
: "Vocalinux on GitHub"
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className="h-4 w-4"
|
||||
strokeWidth={1.75}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{stars !== null ? stars.toLocaleString() : "GitHub"}</span>
|
||||
</a>
|
||||
<button
|
||||
className="nav-toggle"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={navId}
|
||||
aria-label="Toggle menu"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{open ? "Close navigation" : "Open navigation"}
|
||||
</span>
|
||||
<span className="nav-toggle-bars" aria-hidden="true">
|
||||
<i></i>
|
||||
<i></i>
|
||||
<i></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
id={navId}
|
||||
className="mobile-nav shell"
|
||||
aria-label="Site"
|
||||
hidden={!open}
|
||||
>
|
||||
{[...nav, ...extras].map((item) => (
|
||||
<SmartLink
|
||||
key={`m-${item.href}-${item.label}`}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</SmartLink>
|
||||
))}
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={ctaHref}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Install Vocalinux
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main">{children}</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<div className="shell">
|
||||
<div className="footer-inner">
|
||||
<div className="footer-brand">
|
||||
<Link href="/" className="brand">
|
||||
<VocalinuxLogo width={28} height={28} className="h-7 w-7" />
|
||||
<span>Vocalinux</span>
|
||||
</Link>
|
||||
<p>
|
||||
Offline voice typing for Linux. Local engines by default, no
|
||||
required account.
|
||||
</p>
|
||||
<ul className="footer-social">
|
||||
{socialLinks.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a
|
||||
href={item.href}
|
||||
{...(item.href.startsWith("http")
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
>
|
||||
<span
|
||||
className="footer-social-icon"
|
||||
style={{
|
||||
maskImage: `url(${item.icon})`,
|
||||
WebkitMaskImage: `url(${item.icon})`,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{footerGroups.map((group) => (
|
||||
<div className="footer-col" key={group.title}>
|
||||
<h2>{group.title}</h2>
|
||||
<ul>
|
||||
{group.links.map((link) => (
|
||||
<li key={link.href + link.label}>
|
||||
<SmartLink href={link.href}>{link.label}</SmartLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="footer-base">
|
||||
<span>
|
||||
AGPL-3.0. Built in public by{" "}
|
||||
<a
|
||||
href="https://github.com/jatinkrmalik"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Jatin K Malik
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<span>
|
||||
Part of{" "}
|
||||
<a href={VOCAHQ_URL} rel="noopener noreferrer">
|
||||
VocaHQ
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
export function TerminalPrompt() {
|
||||
return (
|
||||
<span className="terminal-prompt">
|
||||
<span className="terminal-user">user</span>
|
||||
<span className="terminal-at">@</span>
|
||||
<span className="terminal-host">linux</span>
|
||||
<span className="terminal-path">:~</span>
|
||||
<span className="terminal-hash">$ </span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalBlock({
|
||||
command,
|
||||
displayCommand,
|
||||
label = "terminal",
|
||||
}: {
|
||||
command: string;
|
||||
displayCommand: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
void navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
}, [command]);
|
||||
|
||||
return (
|
||||
<div className="terminal-panel min-w-0">
|
||||
<div className="terminal-chrome">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied" : "Copy"}
|
||||
className={`terminal-copy${copied ? " is-copied" : ""}`}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" strokeWidth={1.75} />
|
||||
)}
|
||||
</button>
|
||||
<span className="window-title">{label}</span>
|
||||
<span className="linux-controls" aria-hidden="true">
|
||||
<i></i>
|
||||
<i></i>
|
||||
<i className="close"></i>
|
||||
</span>
|
||||
</div>
|
||||
<pre className="max-w-full min-w-0 overflow-x-auto break-all">
|
||||
<TerminalPrompt />
|
||||
{displayCommand}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import React from "react";
|
||||
type Attribute = "class" | "data-theme" | "data-mode";
|
||||
|
||||
// Define the props type with correct attribute type
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
attribute?: Attribute | Attribute[];
|
||||
defaultTheme?: string;
|
||||
enableSystem?: boolean;
|
||||
disableTransitionOnChange?: boolean;
|
||||
storageKey?: string;
|
||||
forcedTheme?: string;
|
||||
themes?: string[];
|
||||
}
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image, { type ImageProps } from "next/image";
|
||||
import React from "react";
|
||||
|
||||
type CommonProps = {
|
||||
src: string;
|
||||
srcDark: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders light and dark screenshot variants. Visibility follows the
|
||||
* `dark` class on `<html>` from next-themes, so the active theme's image
|
||||
* shows without a hydration flash.
|
||||
*
|
||||
* Default loading is "eager" for both siblings so the inactive theme is
|
||||
* already fetched when the user toggles. Callers can still pass "lazy"
|
||||
* for below-the-fold galleries (both siblings share the value).
|
||||
*/
|
||||
export function ThemeScreenshotImg({
|
||||
src,
|
||||
srcDark,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className = "",
|
||||
loading = "eager",
|
||||
}: CommonProps & {
|
||||
width: number;
|
||||
height: number;
|
||||
loading?: "lazy" | "eager";
|
||||
}) {
|
||||
const shared = `${className}`.trim();
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`${shared} dark:hidden`.trim()}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={srcDark}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`${shared} hidden dark:block`.trim()}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ThemeScreenshotNextProps = CommonProps &
|
||||
Omit<ImageProps, "src" | "alt"> & {
|
||||
src: string;
|
||||
srcDark: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* next/image twin for homepage embeds that already use Image.
|
||||
*
|
||||
* `priority` only applies to the light (default) image so Next preloads a
|
||||
* single LCP candidate. The dark twin uses eager loading so theme toggles
|
||||
* still have the asset ready without a second priority preload.
|
||||
*/
|
||||
export function ThemeScreenshotImage({
|
||||
src,
|
||||
srcDark,
|
||||
alt,
|
||||
className = "",
|
||||
priority,
|
||||
loading,
|
||||
...rest
|
||||
}: ThemeScreenshotNextProps) {
|
||||
const shared = `${className}`.trim();
|
||||
const darkLoading = priority ? "eager" : loading;
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${shared} dark:hidden`.trim()}
|
||||
priority={priority}
|
||||
loading={priority ? undefined : loading}
|
||||
{...rest}
|
||||
/>
|
||||
<Image
|
||||
src={srcDark}
|
||||
alt={alt}
|
||||
className={`${shared} hidden dark:block`.trim()}
|
||||
loading={darkLoading}
|
||||
{...rest}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive `/screenshots/dark/<file>` from a light `/screenshots/<file>` path. */
|
||||
export function darkScreenshotPath(src: string): string {
|
||||
if (src.includes("/dark/")) {
|
||||
return src;
|
||||
}
|
||||
return src.replace("/screenshots/", "/screenshots/dark/");
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { motion } from "framer-motion";
|
||||
export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const {
|
||||
theme,
|
||||
setTheme
|
||||
} = useTheme();
|
||||
|
||||
// Wait for component to be mounted to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
if (!mounted) {
|
||||
return <div className="w-9 h-9 bg-muted rounded-md flex items-center justify-center">
|
||||
<span className="sr-only">Loading theme toggle</span>
|
||||
</div>;
|
||||
}
|
||||
return <motion.button whileTap={{
|
||||
scale: 0.95
|
||||
}} onClick={() => setTheme(theme === "dark" ? "light" : "dark")} className="w-8 h-8 sm:w-9 sm:h-9 bg-muted hover:bg-muted/80 rounded-md flex items-center justify-center transition-all" aria-label="Toggle theme">
|
||||
{theme === "dark" ? <Sun className="h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] text-yellow-400" /> : <Moon className="h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] text-slate-700" />}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</motion.button>;
|
||||
}
|
||||
+5
-1
@@ -3,10 +3,14 @@ import { type Metadata } from "next";
|
||||
export const SITE_URL = "https://vocalinux.com";
|
||||
export const SITE_NAME = "Vocalinux";
|
||||
export const DEFAULT_OG_IMAGE_PATH = "/og-image.png";
|
||||
export const DEFAULT_OG_IMAGE_ALT = "Vocalinux - Voice Dictation for Linux";
|
||||
export const DEFAULT_OG_IMAGE_ALT =
|
||||
"Dictate locally. In any app. Offline voice dictation for Linux.";
|
||||
export const TWITTER_HANDLE = "@vocahq";
|
||||
export const VOCAHQ_URL = "https://vocahq.com";
|
||||
export const GITHUB_REPO_URL = "https://github.com/VocaHQ/vocalinux";
|
||||
export const X_URL = "https://x.com/vocahq";
|
||||
export const DISCORD_URL = "https://discord.gg/t6muquAJbm";
|
||||
export const EMAIL_URL = "mailto:hello@vocahq.com";
|
||||
|
||||
interface PageMetadataOptions {
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
DISCORD_URL,
|
||||
EMAIL_URL,
|
||||
GITHUB_REPO_URL,
|
||||
VOCAHQ_URL,
|
||||
X_URL,
|
||||
} from "@/lib/seo";
|
||||
|
||||
export type NavLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const homeNav: NavLink[] = [
|
||||
{ href: "#how-it-works", label: "How it works" },
|
||||
{ href: "#engines", label: "Engines" },
|
||||
{ href: "#privacy", label: "Privacy" },
|
||||
{ href: "#install", label: "Install" },
|
||||
{ href: "#faq", label: "Questions" },
|
||||
];
|
||||
|
||||
export const pageNav: NavLink[] = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/#how-it-works", label: "How it works" },
|
||||
{ href: "/install/", label: "Install" },
|
||||
{ href: "/screenshots/", label: "Screenshots" },
|
||||
{ href: "/faq/", label: "Questions" },
|
||||
];
|
||||
|
||||
export const mobileExtraNav: NavLink[] = [
|
||||
{ href: "/compare/", label: "Compare engines" },
|
||||
{ href: "/changelog/", label: "Changelog" },
|
||||
{ href: "/troubleshooting/", label: "Troubleshooting" },
|
||||
];
|
||||
|
||||
export const socialLinks: { href: string; label: string; icon: string }[] = [
|
||||
{ href: X_URL, label: "X", icon: "/brand/social/x.svg" },
|
||||
{ href: DISCORD_URL, label: "Discord", icon: "/brand/social/discord.svg" },
|
||||
{ href: GITHUB_REPO_URL, label: "GitHub", icon: "/brand/social/github.svg" },
|
||||
{ href: EMAIL_URL, label: "Email hello@vocahq.com", icon: "/brand/social/mail.svg" },
|
||||
];
|
||||
|
||||
export const footerGroups: { title: string; links: NavLink[] }[] = [
|
||||
{
|
||||
title: "Product",
|
||||
links: [
|
||||
{ href: "/#how-it-works", label: "How it works" },
|
||||
{ href: "/screenshots/", label: "Screenshots" },
|
||||
{ href: "/#engines", label: "Engines" },
|
||||
{ href: "/compare/", label: "Compare engines" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Install",
|
||||
links: [
|
||||
{ href: "/#install", label: "Installation" },
|
||||
{ href: "/install/", label: "Distro guides" },
|
||||
{ href: "/install/ubuntu/", label: "Ubuntu" },
|
||||
{ href: "/install/fedora/", label: "Fedora" },
|
||||
{ href: "/install/arch/", label: "Arch" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Guides",
|
||||
links: [
|
||||
{ href: "/offline/", label: "Offline" },
|
||||
{ href: "/wayland/", label: "Wayland" },
|
||||
{ href: "/gpu-acceleration/", label: "GPU acceleration" },
|
||||
{ href: "/desktop-reliability/", label: "Reliability" },
|
||||
{ href: "/remote-api/", label: "Remote API" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Project",
|
||||
links: [
|
||||
{ href: "/faq/", label: "FAQ" },
|
||||
{ href: "/privacy/", label: "Privacy" },
|
||||
{ href: "/changelog/", label: "Changelog" },
|
||||
{ href: GITHUB_REPO_URL, label: "Source" },
|
||||
{ href: VOCAHQ_URL, label: "VocaHQ" },
|
||||
],
|
||||
},
|
||||
];
|
||||
+1801
-195
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user