Website beta v2: UI polish and improvements

- Reorder nav links to match page section order (Demo, Features, Install)
- Split install commands into 3 lines for better readability
- Fix button contrast in dark mode (Install Now, Install Vocalinux)
- Improve live demo mic icon and Ctrl text readability
- Add gradient/border styling to version tag in header
- Hide scrollbars on code blocks globally
- Add favicon using vocalinux.svg
- Add copy button to uninstall command
- Left-align Quick Install header text
- Update footer author link to X/Twitter profile
This commit is contained in:
jatinkrmalik
2026-01-19 17:29:05 -08:00
parent c321f8a2a3
commit e4996a0d1d
7 changed files with 449 additions and 430 deletions
+24 -24
View File
@@ -1,26 +1,26 @@
{
"name": "Vocalinux",
"short_name": "Vocalinux",
"description": "Voice dictation for Linux, finally done right. 100% offline and privacy-focused.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#7c3aed",
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
"name": "Vocalinux",
"short_name": "Vocalinux",
"description": "Voice dictation for Linux, finally done right. 100% offline and privacy-focused.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#7c3aed",
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}
+4 -5
View File
@@ -47,11 +47,10 @@ export const metadata: Metadata = {
publisher: "Vocalinux",
icons: {
icon: [
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon.ico", sizes: "48x48", type: "image/x-icon" }
{ url: "/vocalinux.svg", type: "image/svg+xml" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }]
apple: [{ url: "/vocalinux.svg", type: "image/svg+xml" }],
shortcut: "/vocalinux.svg"
},
manifest: "/site.webmanifest",
appleWebApp: {
@@ -152,4 +151,4 @@ export default function RootLayout({
</body>
</html>
);
}
}
+50 -51
View File
@@ -37,10 +37,14 @@ import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useInView } from "react-intersection-observer";
// The one-liner install command
const oneClickInstallCommand = `curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash`;
// The one-liner install command (split into three lines for display)
const oneClickInstallCommand = `curl \\
-fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh \\
| bash`;
const oneClickInstallNoWhisper = `curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash -s -- --no-whisper`;
const oneClickInstallNoWhisper = `curl \\
-fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh \\
| bash -s -- --no-whisper`;
const FeatureCard = ({
icon,
@@ -147,8 +151,8 @@ export default function HomePage() {
}, []);
const navLinks = [
{ href: "#features", label: "Features" },
{ href: "#demo", label: "Demo" },
{ href: "#features", label: "Features" },
{ href: "#install", label: "Install" },
{ href: "#voice-commands", label: "Commands" },
{ href: "#faq", label: "FAQ" },
@@ -169,7 +173,7 @@ export default function HomePage() {
priority
/>
<span className="font-bold text-lg sm:text-xl">Vocalinux</span>
<span className="hidden sm:inline-block text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full font-medium">
<span className="hidden sm:inline-block text-xs bg-gradient-to-r from-primary/20 to-green-500/20 text-primary border border-primary/30 px-2.5 py-1 rounded-full font-semibold shadow-sm shadow-primary/20">
v0.2.0 Alpha
</span>
</a>
@@ -293,7 +297,7 @@ export default function HomePage() {
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<a
href="#install"
className="inline-flex items-center justify-center gap-2 bg-primary text-primary-foreground hover:bg-primary/90 px-8 py-4 rounded-xl text-lg font-semibold transition-all hover:scale-105 shadow-lg shadow-primary/25"
className="inline-flex items-center justify-center gap-2 bg-primary text-white dark:text-zinc-900 hover:bg-primary/90 px-8 py-4 rounded-xl text-lg font-semibold transition-all hover:scale-105 shadow-lg shadow-primary/25"
>
<Download className="h-5 w-5" />
Install Now — It&apos;s Free
@@ -313,17 +317,17 @@ export default function HomePage() {
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="w-full max-w-5xl mx-auto px-4"
className="w-full max-w-6xl mx-auto px-4"
>
<div className="bg-gradient-to-br from-zinc-900 to-zinc-950 rounded-2xl p-6 sm:p-8 shadow-2xl border border-zinc-800/50 backdrop-blur">
{/* Header */}
<div className="flex items-center gap-3 mb-5">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-green-500/20">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-green-500/20 flex-shrink-0">
<Terminal className="h-5 w-5 text-green-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Quick Install</h3>
<p className="text-xs text-zinc-400">Copy & paste in your terminal</p>
<div className="text-left">
<h3 className="text-lg font-semibold text-white text-left">Quick Install</h3>
<p className="text-xs text-zinc-400 text-left">Copy & paste in your terminal</p>
</div>
</div>
@@ -338,10 +342,9 @@ export default function HomePage() {
<CopyButton text={oneClickInstallCommand} />
</div>
<div className="p-4 sm:p-5">
<div className="font-mono text-sm sm:text-base text-green-400">
<span className="text-zinc-500 select-none">$ </span>
<span className="break-all">{oneClickInstallCommand}</span>
</div>
<pre className="font-mono text-sm sm:text-base text-green-400 text-left whitespace-pre">
<span className="text-zinc-500 select-none">$ </span>{oneClickInstallCommand}
</pre>
</div>
</div>
@@ -394,7 +397,7 @@ export default function HomePage() {
{/* Demo Section - Live Interactive Demo */}
<section id="demo" className="py-16 sm:py-24 px-4 sm:px-6 bg-zinc-50 dark:bg-zinc-900/50">
<div className="max-w-5xl mx-auto">
<div className="max-w-6xl mx-auto">
<FadeInSection>
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold mb-4">
@@ -574,25 +577,21 @@ export default function HomePage() {
<div className="bg-green-500/10 p-2 rounded-lg">
<Sparkles className="h-5 w-5 text-green-500" />
</div>
<div>
<div className="flex-1">
<h3 className="text-xl font-semibold">Recommended: Full Install</h3>
<p className="text-sm text-muted-foreground">
Includes Whisper AI for best accuracy (~5-10 min)
</p>
</div>
<CopyButton text={oneClickInstallCommand} />
</div>
<div className="relative">
<SyntaxHighlighter
language="bash"
style={atomOneDark}
className="rounded-lg text-sm sm:text-base"
>
{oneClickInstallCommand}
</SyntaxHighlighter>
<div className="absolute top-2 right-2">
<CopyButton text={oneClickInstallCommand} />
</div>
</div>
<SyntaxHighlighter
language="bash"
style={atomOneDark}
className="rounded-lg text-sm sm:text-base"
>
{oneClickInstallCommand}
</SyntaxHighlighter>
</div>
{/* Alternative install */}
@@ -601,25 +600,21 @@ export default function HomePage() {
<div className="bg-blue-500/10 p-2 rounded-lg">
<Zap className="h-5 w-5 text-blue-500" />
</div>
<div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Quick Install (VOSK only)</h3>
<p className="text-sm text-muted-foreground">
Faster install, lighter on resources (~2-3 min)
</p>
</div>
<CopyButton text={oneClickInstallNoWhisper} />
</div>
<div className="relative">
<SyntaxHighlighter
language="bash"
style={atomOneDark}
className="rounded-lg text-sm"
>
{oneClickInstallNoWhisper}
</SyntaxHighlighter>
<div className="absolute top-2 right-2">
<CopyButton text={oneClickInstallNoWhisper} />
</div>
</div>
<SyntaxHighlighter
language="bash"
style={atomOneDark}
className="rounded-lg text-sm"
>
{oneClickInstallNoWhisper}
</SyntaxHighlighter>
</div>
{/* What the installer does */}
@@ -676,16 +671,20 @@ export default function HomePage() {
</ul>
</div>
<div className="bg-white dark:bg-zinc-800 p-6 rounded-xl border border-zinc-200 dark:border-zinc-700">
<h4 className="font-semibold mb-4 flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
Uninstall
</h4>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold flex items-center gap-2">
<Terminal className="h-5 w-5 text-primary" />
Uninstall
</h4>
<CopyButton text="curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/uninstall.sh | bash" />
</div>
<p className="text-sm text-muted-foreground mb-3">
Clean removal in one command:
</p>
<code className="block bg-zinc-100 dark:bg-zinc-900 text-zinc-800 dark:text-zinc-200 px-4 py-2 rounded text-sm overflow-x-auto">
curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/uninstall.sh | bash
</code>
<pre className="bg-zinc-100 dark:bg-zinc-900 text-zinc-800 dark:text-zinc-200 px-4 py-3 rounded text-xs font-mono whitespace-pre overflow-x-auto">
{`curl -fsSL \\
https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/uninstall.sh | bash`}
</pre>
</div>
</div>
</FadeInSection>
@@ -928,7 +927,7 @@ export default function HomePage() {
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="#install"
className="inline-flex items-center justify-center gap-2 bg-primary text-primary-foreground hover:bg-primary/90 px-8 py-4 rounded-xl text-lg font-semibold transition-all hover:scale-105 shadow-lg shadow-primary/25"
className="inline-flex items-center justify-center gap-2 bg-primary text-white dark:text-zinc-900 hover:bg-primary/90 px-8 py-4 rounded-xl text-lg font-semibold transition-all hover:scale-105 shadow-lg shadow-primary/25"
>
<Download className="h-5 w-5" />
Install Vocalinux
@@ -1052,7 +1051,7 @@ export default function HomePage() {
<p className="text-zinc-500 text-sm flex items-center gap-1">
Made with <Heart className="h-4 w-4 text-red-500" /> by{" "}
<a
href="https://github.com/jatinkrmalik"
href="https://x.com/intent/user?screen_name=jatinkrmalik"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:text-white transition-colors"
+5 -5
View File
@@ -6,10 +6,10 @@ import Image from "next/image";
// Logo component using the actual application icon
export const VocalinuxLogo: React.FC<{ className?: string }> = ({ className = "" }) => {
return (
<Image
src="/vocalinux.svg"
alt="Vocalinux Logo"
width={36}
<Image
src="/vocalinux.svg"
alt="Vocalinux Logo"
width={36}
height={36}
className={className}
priority
@@ -27,4 +27,4 @@ export const VocalinuxLogoInline: React.FC<React.SVGProps<SVGSVGElement>> = (pro
<path d="M404.61 164.01c0-28.19-11.08-38.24-11.08-38.24-5.54-5.54-12.75-8.31-20-8.31s-14.46 2.77-20 8.31l-7.65 7.65-7.65-7.65c-11.04-11.04-28.96-11.04-40 0l-7.65 7.65-7.65-7.65c-11.04-11.04-28.96-11.04-40 0-11.08 11.08-11.08 28.92 0 40l47.65 47.57v44.62c0 14.15 11.51 25.65 25.65 25.65 14.15 0 25.65-11.51 25.65-25.65v-44.62l47.65-47.57c5.54-5.54 8.31-12.75 8.31-20s-2.77-14.46-8.31-20c-1.67-1.67-1.67-4.38 0-6.04 10.88 4.95 20.73 13.84 20.73 24.27zm-80.82 84.97v19.07c0 9.44-7.68 17.11-17.11 17.11s-17.11-7.67-17.11-17.11v-19.07l17.11 17.11 17.11-17.11zm24.27-48.27l-41.38 41.38L265.3 200.7c-7.37-7.37-7.37-19.32 0-26.69 7.37-7.37 19.32-7.37 26.69 0l14.69 14.69 14.69-14.69c3.56-3.56 8.3-5.52 13.34-5.52s9.78 1.96 13.35 5.52a4.233 4.233 0 006.04 0c3.56-3.56 8.3-5.52 13.34-5.52s9.78 1.96 13.34 5.52c7.37 7.37 7.37 19.32 0 26.69z" fill="currentColor" />
</svg>
);
};
};
+340 -341
View File
@@ -6,383 +6,382 @@ import { Mic, MicOff, AlertCircle, Chrome, Globe, Volume2, Sparkles, Keyboard }
// Define types for Web Speech API
interface SpeechRecognitionEvent {
results: SpeechRecognitionResultList;
resultIndex: number;
results: SpeechRecognitionResultList;
resultIndex: number;
}
interface SpeechRecognitionErrorEvent {
error: string;
message?: string;
error: string;
message?: string;
}
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
abort(): void;
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
abort(): void;
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;
}
declare global {
interface Window {
SpeechRecognition?: new () => SpeechRecognition;
webkitSpeechRecognition?: new () => SpeechRecognition;
}
interface Window {
SpeechRecognition?: new () => SpeechRecognition;
webkitSpeechRecognition?: new () => SpeechRecognition;
}
}
export function LiveDemo() {
const [isSupported, setIsSupported] = useState<boolean | null>(null);
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState("");
const [interimTranscript, setInterimTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [lastCtrlPress, setLastCtrlPress] = useState<number | null>(null);
const [showCtrlHint, setShowCtrlHint] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const isListeningRef = useRef(false);
const [isSupported, setIsSupported] = useState<boolean | null>(null);
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState("");
const [interimTranscript, setInterimTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [lastCtrlPress, setLastCtrlPress] = useState<number | null>(null);
const [showCtrlHint, setShowCtrlHint] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const isListeningRef = useRef(false);
// Keep ref in sync with state for use in event handlers
useEffect(() => {
isListeningRef.current = isListening;
}, [isListening]);
// Keep ref in sync with state for use in event handlers
useEffect(() => {
isListeningRef.current = isListening;
}, [isListening]);
// Check for browser support
useEffect(() => {
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
setIsSupported(!!SpeechRecognitionAPI);
}, []);
// Check for browser support
useEffect(() => {
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
setIsSupported(!!SpeechRecognitionAPI);
}, []);
// Initialize recognition
const initRecognition = useCallback(() => {
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognitionAPI) return null;
// Initialize recognition
const initRecognition = useCallback(() => {
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognitionAPI) return null;
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "en-US";
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "en-US";
recognition.onstart = () => {
setIsListening(true);
setError(null);
setHasPermission(true);
};
recognition.onstart = () => {
setIsListening(true);
setError(null);
setHasPermission(true);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = "";
let final = "";
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = "";
let final = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
final += transcript;
} else {
interim += transcript;
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
final += transcript;
} else {
interim += transcript;
}
}
if (final) {
setTranscript((prev) => prev + final + " ");
setInterimTranscript("");
} else {
setInterimTranscript(interim);
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === "not-allowed") {
setHasPermission(false);
setError("Microphone access denied. Please allow microphone permissions.");
} else if (event.error === "no-speech") {
// Ignore no-speech errors
} else {
setError(`Error: ${event.error}`);
}
setIsListening(false);
};
return recognition;
}, []);
const startListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
}
recognitionRef.current = initRecognition();
if (recognitionRef.current) {
setTranscript("");
setInterimTranscript("");
try {
recognitionRef.current.start();
} catch (e) {
setError("Could not start recognition. Please try again.");
}
}
}, [initRecognition]);
if (final) {
setTranscript((prev) => prev + final + " ");
const stopListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
}
}, []);
// Toggle listening state
const toggleListening = useCallback(() => {
if (isListeningRef.current) {
stopListening();
} else {
startListening();
}
}, [startListening, stopListening]);
const clearTranscript = useCallback(() => {
setTranscript("");
setInterimTranscript("");
} else {
setInterimTranscript(interim);
}
};
}, []);
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === "not-allowed") {
setHasPermission(false);
setError("Microphone access denied. Please allow microphone permissions.");
} else if (event.error === "no-speech") {
// Ignore no-speech errors
} else {
setError(`Error: ${event.error}`);
}
setIsListening(false);
};
// Double-tap Ctrl detection
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Control") return;
return recognition;
}, []);
const now = Date.now();
const DOUBLE_TAP_DELAY = 400;
const startListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
recognitionRef.current = initRecognition();
if (recognitionRef.current) {
setTranscript("");
setInterimTranscript("");
try {
recognitionRef.current.start();
} catch (e) {
setError("Could not start recognition. Please try again.");
}
}
}, [initRecognition]);
// Show hint on first Ctrl press
setShowCtrlHint(true);
setTimeout(() => setShowCtrlHint(false), 600);
const stopListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
}
}, []);
if (lastCtrlPress && now - lastCtrlPress <= DOUBLE_TAP_DELAY) {
// Double-tap detected!
toggleListening();
setLastCtrlPress(null);
setShowCtrlHint(false);
} else {
setLastCtrlPress(now);
}
};
// Toggle listening state
const toggleListening = useCallback(() => {
if (isListeningRef.current) {
stopListening();
} else {
startListening();
}
}, [startListening, stopListening]);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lastCtrlPress, toggleListening]);
const clearTranscript = useCallback(() => {
setTranscript("");
setInterimTranscript("");
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
// Double-tap Ctrl detection
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Control") return;
const now = Date.now();
const DOUBLE_TAP_DELAY = 400;
// Show hint on first Ctrl press
setShowCtrlHint(true);
setTimeout(() => setShowCtrlHint(false), 600);
if (lastCtrlPress && now - lastCtrlPress <= DOUBLE_TAP_DELAY) {
// Double-tap detected!
toggleListening();
setLastCtrlPress(null);
setShowCtrlHint(false);
} else {
setLastCtrlPress(now);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lastCtrlPress, toggleListening]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
// Not supported fallback
if (isSupported === false) {
return (
<div className="bg-zinc-900 rounded-2xl p-6 sm:p-8 border border-zinc-800">
<div className="text-center">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-yellow-500/10 mb-4">
<AlertCircle className="h-8 w-8 text-yellow-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">
Browser Demo Not Available
</h3>
<p className="text-zinc-400 mb-6 max-w-md mx-auto">
Your browser doesn&apos;t support the Web Speech API. Try opening this page in:
</p>
<div className="flex justify-center gap-4 mb-6">
<div className="flex items-center gap-2 text-zinc-300">
<Chrome className="h-5 w-5" />
<span>Chrome</span>
// Not supported fallback
if (isSupported === false) {
return (
<div className="bg-zinc-900 rounded-2xl p-6 sm:p-8 border border-zinc-800">
<div className="text-center">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-yellow-500/10 mb-4">
<AlertCircle className="h-8 w-8 text-yellow-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">
Browser Demo Not Available
</h3>
<p className="text-zinc-400 mb-6 max-w-md mx-auto">
Your browser doesn&apos;t support the Web Speech API. Try opening this page in:
</p>
<div className="flex justify-center gap-4 mb-6">
<div className="flex items-center gap-2 text-zinc-300">
<Chrome className="h-5 w-5" />
<span>Chrome</span>
</div>
<div className="flex items-center gap-2 text-zinc-300">
<Globe className="h-5 w-5" />
<span>Edge</span>
</div>
</div>
<p className="text-sm text-zinc-500">
Or better yet — <a href="#install" className="text-primary hover:underline">install Vocalinux</a> for
the full offline experience with Whisper AI!
</p>
</div>
</div>
<div className="flex items-center gap-2 text-zinc-300">
<Globe className="h-5 w-5" />
<span>Edge</span>
);
}
// Loading state
if (isSupported === null) {
return (
<div className="bg-zinc-900 rounded-2xl p-8 border border-zinc-800 animate-pulse">
<div className="h-48 flex items-center justify-center">
<div className="text-zinc-500">Checking browser support...</div>
</div>
</div>
</div>
<p className="text-sm text-zinc-500">
Or better yet — <a href="#install" className="text-primary hover:underline">install Vocalinux</a> for
the full offline experience with Whisper AI!
</p>
</div>
</div>
);
}
);
}
// Loading state
if (isSupported === null) {
return (
<div className="bg-zinc-900 rounded-2xl p-8 border border-zinc-800 animate-pulse">
<div className="h-48 flex items-center justify-center">
<div className="text-zinc-500">Checking browser support...</div>
</div>
</div>
);
}
return (
<div className="bg-gradient-to-br from-zinc-900 to-zinc-950 rounded-2xl overflow-hidden border border-zinc-800">
{/* Header */}
<div className="px-4 sm:px-6 py-3 bg-zinc-800/50 border-b border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-red-500/70" />
<div className="h-3 w-3 rounded-full bg-yellow-500/70" />
<div className="h-3 w-3 rounded-full bg-green-500/70" />
</div>
<span className="text-sm text-zinc-400 font-medium">
Live Browser Demo
</span>
<span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full">
Web Speech API
</span>
</div>
{transcript && (
<button
onClick={clearTranscript}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Clear
</button>
)}
</div>
{/* Main content */}
<div className="p-6 sm:p-8">
{/* Error message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3"
>
<AlertCircle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-400">{error}</p>
{hasPermission === false && (
<p className="text-xs text-red-400/70 mt-1">
Click the lock icon in your browser&apos;s address bar to enable microphone access.
</p>
<div className="bg-gradient-to-br from-zinc-900 to-zinc-950 rounded-2xl overflow-hidden border border-zinc-800">
{/* Header */}
<div className="px-4 sm:px-6 py-3 bg-zinc-800/50 border-b border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-red-500/70" />
<div className="h-3 w-3 rounded-full bg-yellow-500/70" />
<div className="h-3 w-3 rounded-full bg-green-500/70" />
</div>
<span className="text-sm text-zinc-400 font-medium">
Live Browser Demo
</span>
<span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full">
Web Speech API
</span>
</div>
{transcript && (
<button
onClick={clearTranscript}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Clear
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Microphone button */}
<div className="flex flex-col items-center mb-6">
{/* Double-tap Ctrl hint */}
<AnimatePresence>
{showCtrlHint && !isListening && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-4 px-4 py-2 bg-primary/20 rounded-full text-sm text-primary flex items-center gap-2"
>
<Keyboard className="h-4 w-4" />
Tap Ctrl again to start!
</motion.div>
)}
</AnimatePresence>
<motion.button
onClick={toggleListening}
className={`relative h-20 w-20 sm:h-24 sm:w-24 rounded-full flex items-center justify-center transition-all ${
isListening
? "bg-red-500 hover:bg-red-600 shadow-lg shadow-red-500/30"
: "bg-primary hover:bg-primary/90 shadow-lg shadow-primary/30"
}`}
whileTap={{ scale: 0.95 }}
aria-label={isListening ? "Stop listening" : "Start listening"}
>
{/* Pulse animation when listening */}
{isListening && (
<>
<motion.div
className="absolute inset-0 rounded-full bg-red-500"
animate={{ scale: [1, 1.2], opacity: [0.5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
<motion.div
className="absolute inset-0 rounded-full bg-red-500"
animate={{ scale: [1, 1.4], opacity: [0.3, 0] }}
transition={{ duration: 1.5, repeat: Infinity, delay: 0.5 }}
/>
</>
)}
{isListening ? (
<MicOff className="h-8 w-8 sm:h-10 sm:w-10 text-white relative z-10" />
) : (
<Mic className="h-8 w-8 sm:h-10 sm:w-10 text-white relative z-10" />
)}
</motion.button>
<div className="text-center mt-4">
{isListening ? (
<p className="text-sm text-zinc-400">
<span className="flex items-center justify-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
Listening... Double-tap <kbd className="px-1.5 py-0.5 bg-zinc-700 rounded text-xs mx-1">Ctrl</kbd> or click to stop
</span>
</p>
) : (
<div className="space-y-1">
<p className="text-sm text-zinc-300">
Double-tap <kbd className="px-1.5 py-0.5 bg-zinc-700 rounded text-xs mx-1">Ctrl</kbd> to start
</p>
<p className="text-xs text-zinc-500">or click the microphone</p>
</div>
)}
</div>
</div>
{/* Transcript area */}
<div className="bg-zinc-950/50 rounded-xl p-4 sm:p-6 min-h-[120px] border border-zinc-800/50">
{transcript || interimTranscript ? (
<div className="text-zinc-200 leading-relaxed">
<span>{transcript}</span>
{interimTranscript && (
<span className="text-zinc-500 italic">{interimTranscript}</span>
)}
</div>
) : (
<div className="text-zinc-500 text-center py-4">
<Volume2 className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Your transcribed speech will appear here</p>
</div>
)}
</div>
{/* Info banner */}
<div className="mt-6 flex items-start gap-3 p-4 bg-primary/5 rounded-lg border border-primary/10">
<Sparkles className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-zinc-300">
<strong>This is just a preview!</strong> The browser demo uses Web Speech API (cloud-based).
</p>
<p className="text-zinc-500 mt-1">
Vocalinux runs <strong>100% offline</strong> using Whisper AI — no cloud, no data sharing,
complete privacy. <a href="#install" className="text-primary hover:underline">Install it</a> to
experience the real deal!
</p>
</div>
{/* Main content */}
<div className="p-6 sm:p-8">
{/* Error message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3"
>
<AlertCircle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-400">{error}</p>
{hasPermission === false && (
<p className="text-xs text-red-400/70 mt-1">
Click the lock icon in your browser&apos;s address bar to enable microphone access.
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Microphone button */}
<div className="flex flex-col items-center mb-6">
{/* Double-tap Ctrl hint */}
<AnimatePresence>
{showCtrlHint && !isListening && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-4 px-4 py-2 bg-primary/20 rounded-full text-sm text-primary flex items-center gap-2"
>
<Keyboard className="h-4 w-4" />
Tap Ctrl again to start!
</motion.div>
)}
</AnimatePresence>
<motion.button
onClick={toggleListening}
className={`relative h-20 w-20 sm:h-24 sm:w-24 rounded-full flex items-center justify-center transition-all ${isListening
? "bg-red-500 hover:bg-red-600 shadow-lg shadow-red-500/30"
: "bg-primary hover:bg-primary/90 shadow-lg shadow-primary/30"
}`}
whileTap={{ scale: 0.95 }}
aria-label={isListening ? "Stop listening" : "Start listening"}
>
{/* Pulse animation when listening */}
{isListening && (
<>
<motion.div
className="absolute inset-0 rounded-full bg-red-500"
animate={{ scale: [1, 1.2], opacity: [0.5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
<motion.div
className="absolute inset-0 rounded-full bg-red-500"
animate={{ scale: [1, 1.4], opacity: [0.3, 0] }}
transition={{ duration: 1.5, repeat: Infinity, delay: 0.5 }}
/>
</>
)}
{isListening ? (
<MicOff className="h-8 w-8 sm:h-10 sm:w-10 text-zinc-900 relative z-10" />
) : (
<Mic className="h-8 w-8 sm:h-10 sm:w-10 text-zinc-900 relative z-10" />
)}
</motion.button>
<div className="text-center mt-4">
{isListening ? (
<p className="text-sm text-zinc-300">
<span className="flex items-center justify-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
Listening... Double-tap <kbd className="px-2 py-1 bg-zinc-800 border border-zinc-600 rounded text-xs mx-1 text-zinc-200 font-mono">Ctrl</kbd> or click to stop
</span>
</p>
) : (
<div className="space-y-1">
<p className="text-sm text-zinc-200">
Double-tap <kbd className="px-2 py-1 bg-zinc-800 border border-zinc-600 rounded text-xs mx-1 text-zinc-200 font-mono">Ctrl</kbd> to start
</p>
<p className="text-xs text-zinc-400">or click the microphone</p>
</div>
)}
</div>
</div>
{/* Transcript area */}
<div className="bg-zinc-950/50 rounded-xl p-4 sm:p-6 min-h-[120px] border border-zinc-800/50">
{transcript || interimTranscript ? (
<div className="text-zinc-200 leading-relaxed">
<span>{transcript}</span>
{interimTranscript && (
<span className="text-zinc-500 italic">{interimTranscript}</span>
)}
</div>
) : (
<div className="text-zinc-500 text-center py-4">
<Volume2 className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Your transcribed speech will appear here</p>
</div>
)}
</div>
{/* Info banner */}
<div className="mt-6 flex items-start gap-3 p-4 bg-primary/5 rounded-lg border border-primary/10">
<Sparkles className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-zinc-300">
<strong>This is just a preview!</strong> The browser demo uses Web Speech API (cloud-based).
</p>
<p className="text-zinc-500 mt-1">
Vocalinux runs <strong>100% offline</strong> using Whisper AI — no cloud, no data sharing,
complete privacy. <a href="#install" className="text-primary hover:underline">Install it</a> to
experience the real deal!
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
);
}
+4 -4
View File
@@ -12,7 +12,7 @@ export function Logo({
textClassName
}: LogoProps) {
return <div className={cn("flex items-center gap-2", className)}>
<VocalinuxLogo className={cn("h-8 w-8", iconClassName)} />
<span className={cn("font-bold text-xl", textClassName)}>Vocalinux</span>
</div>;
}
<VocalinuxLogo className={cn("h-8 w-8", iconClassName)} />
<span className={cn("font-bold text-xl", textClassName)}>Vocalinux</span>
</div>;
}
+22
View File
@@ -87,6 +87,28 @@
}
}
/* Hide scrollbars but keep scroll functionality */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Hide scrollbars on syntax highlighter code blocks */
pre[class*="language-"],
code {
-ms-overflow-style: none;
scrollbar-width: none;
}
pre[class*="language-"]::-webkit-scrollbar,
code::-webkit-scrollbar {
display: none;
}
nextjs-portal {
display: none;
}