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:
+24
-24
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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"
|
||||
|
||||
@@ -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
@@ -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'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'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'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'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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user