Website Beta v2: UI Polish and Improvements (#56)
* feat(web): Complete website overhaul for beta release Major updates to make the website beta-ready: ## Content & Design - Completely rewritten homepage with modern, catchy design - Prominent one-click installation command in hero section - Clear value proposition: 'Voice Dictation for Linux, Finally Done Right' - Trust indicators (100% Offline, Privacy First, Open Source, X11/Wayland) - Interactive demo section encouraging users to try double-tap Ctrl - Feature cards highlighting key benefits - Detailed installation section with both full and quick install options - Voice commands reference section - Speech engines comparison (Whisper AI vs VOSK) - Comprehensive FAQ section - Modern CTA section and footer ## SEO Improvements - Added comprehensive meta tags with proper descriptions - Open Graph and Twitter Card meta tags with proper images - JSON-LD structured data for SoftwareApplication schema - Added robots.txt for search engine crawling - Added sitemap.xml for better indexing - Added site.webmanifest for PWA support - Proper canonical URL and meta keywords - Author attribution and GPL-3.0 license info ## Technical - Removed unused creatr-devtools dependency - Real GitHub stars fetching from API - Improved responsive design - Better accessibility with proper aria labels - Clean, maintainable component structure The website now properly reflects the v0.2.0-alpha release features and provides a clear path for users to install and use Vocalinux. * feat(web): Add live demo, improve logo and install UX Changes: - Use actual app logo SVG image for consistent branding - Added live browser-based speech recognition demo using Web Speech API - Allows users to try voice-to-text directly in their browser - Clear indication that the demo uses cloud-based Web Speech API - Prominent call-to-action to install Vocalinux for offline experience - Redesigned one-click install box with better visual hierarchy - Left-aligned layout with clear instructions - Terminal-style command display with copy button - Added compatibility info and install time estimates - Improved footer and feature section with consistent logo usage * feat(web): Add double-tap Ctrl to trigger live demo - Added keyboard detection for double-tap Ctrl (same as the real app) - Shows visual hint 'Tap Ctrl again to start!' on first Ctrl press - Instructions updated to highlight Ctrl+Ctrl as primary activation - Click still works as alternative method - Double-tap Ctrl also stops recording when active * fix(web): Remove duplicate DictationOverlay that conflicted with LiveDemo The old DictationOverlay was still responding to double-tap Ctrl alongside the new LiveDemo component, causing two overlays to appear simultaneously. Removed the page-level useDoublePress hook and DictationOverlay since the LiveDemo now handles double-tap Ctrl detection internally. * fix(web): Improve Quick Install section layout and readability - Changed to stacked vertical layout for cleaner appearance - Command now wraps with break-all instead of horizontal scroll - Simplified compatibility text to fit better - Better spacing and padding throughout - Responsive bottom info section * fix(web): Make Quick Install box wider for better text fit Changed max-w-4xl to max-w-5xl and added horizontal padding so the curl command doesn't wrap as much on medium-sized screens. * 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:
Binary file not shown.
|
After Width: | Height: | Size: 6.5 MiB |
@@ -0,0 +1,8 @@
|
||||
# Robots.txt for Vocalinux
|
||||
# https://vocalinux.com
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemap location
|
||||
Sitemap: https://vocalinux.com/sitemap.xml
|
||||
@@ -0,0 +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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://vocalinux.com/</loc>
|
||||
<lastmod>2026-01-19</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<!-- Placeholder for microphone off icon -->
|
||||
<circle cx="64" cy="64" r="60" fill="#e74c3c" />
|
||||
<rect x="44" y="40" width="40" height="60" rx="20" fill="#7f8c8d" />
|
||||
<line x1="40" y1="88" x2="88" y2="40" stroke="#e74c3c" stroke-width="8" />
|
||||
<!-- This is a placeholder. Replace with actual custom design. -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<!-- Placeholder for microphone processing icon -->
|
||||
<circle cx="64" cy="64" r="60" fill="#f39c12" />
|
||||
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
|
||||
<circle cx="64" cy="40" r="8" fill="#f39c12">
|
||||
<animate attributeName="cy" values="40;50;40" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="48" cy="60" r="8" fill="#f39c12">
|
||||
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="80" cy="60" r="8" fill="#f39c12">
|
||||
<animate attributeName="cy" values="60;70;60" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<!-- This is a placeholder. Replace with actual custom design. -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 836 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<!-- Placeholder for microphone on/active icon -->
|
||||
<circle cx="64" cy="64" r="60" fill="#2ecc71" />
|
||||
<rect x="44" y="40" width="40" height="60" rx="20" fill="#2c3e50" />
|
||||
<path d="M 40,78 Q 64,88 88,78" stroke="#2ecc71" stroke-width="4" fill="none" />
|
||||
<path d="M 40,88 Q 64,98 88,88" stroke="#2ecc71" stroke-width="4" fill="none" />
|
||||
<!-- This is a placeholder. Replace with actual custom design. -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 560 B |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 308 KiB |
+109
-42
@@ -3,44 +3,54 @@ import React from "react";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { type Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { DevtoolsProvider } from 'creatr-devtools';
|
||||
|
||||
export const viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1
|
||||
maximumScale: 5,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#18181b" },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://vocalinux.com"),
|
||||
title: {
|
||||
default: "Vocalinux - Open-Source Voice Dictation for Linux",
|
||||
default: "Vocalinux - Voice Dictation for Linux, Finally Done Right",
|
||||
template: "%s | Vocalinux"
|
||||
},
|
||||
description: "Open-source offline voice dictation for Linux using Whisper models. Privacy-focused with no cloud dependencies.",
|
||||
description: "Free, open-source voice dictation for Linux. 100% offline with Whisper AI. Double-tap Ctrl to start. Works everywhere - X11 & Wayland. One-click install.",
|
||||
applicationName: "Vocalinux",
|
||||
keywords: ["voice dictation", "linux", "speech recognition", "whisper", "offline", "privacy", "x11", "wayland", "open-source"],
|
||||
authors: [{
|
||||
name: "Vocalinux Team"
|
||||
}],
|
||||
creator: "Vocalinux Team",
|
||||
publisher: "Vocalinux Team",
|
||||
keywords: [
|
||||
"voice dictation linux",
|
||||
"speech to text linux",
|
||||
"linux voice typing",
|
||||
"whisper linux",
|
||||
"vosk linux",
|
||||
"offline voice dictation",
|
||||
"privacy voice recognition",
|
||||
"speech recognition linux",
|
||||
"dictation software ubuntu",
|
||||
"voice typing fedora",
|
||||
"wayland voice dictation",
|
||||
"x11 speech to text",
|
||||
"open source dictation",
|
||||
"free speech recognition",
|
||||
"linux accessibility",
|
||||
"hands free typing linux",
|
||||
"voice commands linux",
|
||||
"GTK voice dictation"
|
||||
],
|
||||
authors: [{ name: "Jatin K Malik", url: "https://github.com/jatinkrmalik" }],
|
||||
creator: "Jatin K Malik",
|
||||
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"
|
||||
}],
|
||||
apple: [{
|
||||
url: "/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png"
|
||||
}]
|
||||
icon: [
|
||||
{ url: "/vocalinux.svg", type: "image/svg+xml" },
|
||||
],
|
||||
apple: [{ url: "/vocalinux.svg", type: "image/svg+xml" }],
|
||||
shortcut: "/vocalinux.svg"
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
appleWebApp: {
|
||||
@@ -54,34 +64,91 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://github.com/jatinkrmalik/vocalinux",
|
||||
url: "https://vocalinux.com",
|
||||
siteName: "Vocalinux",
|
||||
title: "Vocalinux - Open-Source Voice Dictation for Linux",
|
||||
description: "Open-source offline voice dictation for Linux using Whisper models. Works with X11 and Wayland.",
|
||||
title: "Vocalinux - Voice Dictation for Linux, Finally Done Right",
|
||||
description: "Free, open-source voice dictation for Linux. 100% offline & private with Whisper AI and VOSK. Double-tap Ctrl to start dictating in any app.",
|
||||
images: [{
|
||||
url: "https://picsum.photos/200",
|
||||
width: 1280,
|
||||
height: 640,
|
||||
alt: "Vocalinux Open-Source Voice Dictation"
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Vocalinux - Voice Dictation for Linux"
|
||||
}]
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Vocalinux - Open-Source Voice Dictation for Linux",
|
||||
description: "Open-source offline voice dictation for Linux using Whisper models. Works with X11 and Wayland.",
|
||||
images: ["https://picsum.photos/200"]
|
||||
}
|
||||
title: "Vocalinux - Voice Dictation for Linux, Finally Done Right",
|
||||
description: "Free, open-source voice dictation for Linux. 100% offline & private. Double-tap Ctrl to start dictating in any app.",
|
||||
images: ["/og-image.png"],
|
||||
creator: "@jatinkrmalik"
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
canonical: "https://vocalinux.com",
|
||||
},
|
||||
category: "Technology",
|
||||
};
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <html lang="en" className={`${GeistSans.variable} scroll-smooth`} suppressHydrationWarning>
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Vocalinux",
|
||||
"applicationCategory": "UtilitiesApplication",
|
||||
"operatingSystem": "Linux",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"description": "Free, open-source voice dictation for Linux. 100% offline with Whisper AI and VOSK. Works with X11 and Wayland.",
|
||||
"softwareVersion": "0.2.0-alpha",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Jatin K Malik",
|
||||
"url": "https://github.com/jatinkrmalik"
|
||||
},
|
||||
"url": "https://vocalinux.com",
|
||||
"downloadUrl": "https://github.com/jatinkrmalik/vocalinux",
|
||||
"screenshot": "https://vocalinux.com/og-image.png",
|
||||
"featureList": [
|
||||
"100% offline voice recognition",
|
||||
"Privacy-focused - no cloud dependencies",
|
||||
"Works with all Linux applications",
|
||||
"X11 and Wayland support",
|
||||
"Double-tap Ctrl activation",
|
||||
"Whisper AI and VOSK engines",
|
||||
"Voice commands for punctuation",
|
||||
"System tray integration"
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${GeistSans.variable} scroll-smooth`} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>;
|
||||
}
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+962
-846
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
export const VocalinuxLogo: React.FC<React.SVGProps<SVGSVGElement>> = props => {
|
||||
return <svg width="36" height="36" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
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}
|
||||
height={36}
|
||||
className={className}
|
||||
priority
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Alternative: Inline SVG version for when color customization is needed
|
||||
export const VocalinuxLogoInline: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width="36" height="36" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M243.33 120.92c-58.29 1.8-107.88 51.39-107.88 110.12v100.24c0 20.87 5.16 39.95 14.02 57.07l-44.12 44.12c-3.27 3.27-3.27 8.58 0 11.86 1.64 1.64 3.79 2.46 5.93 2.46 2.14 0 4.29-.82 5.93-2.46l43.39-43.39a107.388 107.388 0 0074.73 30.13h.01c20.94 0 40.83-6.05 57.99-16.52 23.86-14.53 42.22-37.58 49.7-64.48.8-2.89-1.18-5.8-4.17-6.14-2.99-.33-5.57 1.79-5.75 4.8-2.91 48.11-43.05 85.82-91.5 85.82h-.76c-50.83-1.57-93.56-42.09-93.56-93.67V231.04c0-51.11 41.57-93.67 92.68-93.67 46.36 0 86.43 35.37 93.54 80.62.41 2.64 2.69 4.56 5.38 4.5 2.67-.06 4.84-2.1 5.13-4.75 7.65-71.11-46.43-98.62-99.69-96.82z" fill="currentColor" />
|
||||
<path d="M299.33 256.96c0-2.36-1.91-4.27-4.27-4.27s-4.27 1.91-4.27 4.27v62.09c0 2.36 1.91 4.27 4.27 4.27s4.27-1.91 4.27-4.27v-62.09z" fill="currentColor" />
|
||||
<path d="M361.42 164.01v-17.06c0-2.36-1.91-4.27-4.27-4.27s-4.27 1.91-4.27 4.27v17.06c0 2.36 1.91 4.27 4.27 4.27s4.27-1.91 4.27-4.27zM336.62 164.01v-17.06c0-2.36-1.91-4.27-4.27-4.27s-4.27 1.91-4.27 4.27v17.06c0 2.36 1.91 4.27 4.27 4.27s4.27-1.91 4.27-4.27zM311.82 164.01v-17.06c0-2.36-1.91-4.27-4.27-4.27s-4.27 1.91-4.27 4.27v17.06c0 2.36 1.91 4.27 4.27 4.27s4.27-1.91 4.27-4.27z" fill="currentColor" />
|
||||
<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>;
|
||||
};
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Mic, MicOff, AlertCircle, Chrome, Globe, Volume2, Sparkles, Keyboard } from "lucide-react";
|
||||
|
||||
// Define types for Web Speech API
|
||||
interface SpeechRecognitionEvent {
|
||||
results: SpeechRecognitionResultList;
|
||||
resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent {
|
||||
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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
// 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";
|
||||
|
||||
recognition.onstart = () => {
|
||||
setIsListening(true);
|
||||
setError(null);
|
||||
setHasPermission(true);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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("");
|
||||
}, []);
|
||||
|
||||
// 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 text-primary", 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