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
This commit is contained in:
jatinkrmalik
2026-01-19 16:47:34 -08:00
parent 02c3a21931
commit 612e6811ed
4 changed files with 417 additions and 60 deletions
+71 -56
View File
@@ -2,8 +2,10 @@
import React, { useState, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import Image from "next/image";
import { useDoublePress } from "@/hooks/use-double-press";
import { DictationOverlay } from "@/components/dictation-overlay";
import { LiveDemo } from "@/components/live-demo";
import {
Mic,
Terminal,
@@ -30,9 +32,9 @@ import {
Volume2,
Sparkles,
Heart,
ExternalLink,
} from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import { VocalinuxLogo } from "@/components/icons";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useInView } from "react-intersection-observer";
@@ -170,7 +172,14 @@ export default function HomePage() {
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-md border-b border-border">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<a href="/" className="flex items-center gap-2 group">
<VocalinuxLogo className="h-7 w-7 sm:h-8 sm:w-8 text-primary transition-transform group-hover:scale-110" />
<Image
src="/vocalinux.svg"
alt="Vocalinux Logo"
width={32}
height={32}
className="h-7 w-7 sm:h-8 sm:w-8 transition-transform group-hover:scale-110"
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">
v0.2.0 Alpha
@@ -311,29 +320,67 @@ export default function HomePage() {
</div>
</motion.div>
{/* One-Click Install Box */}
{/* One-Click Install Box - Redesigned */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="max-w-3xl mx-auto"
className="max-w-4xl mx-auto"
>
<div className="bg-zinc-900 rounded-2xl p-6 shadow-2xl border border-zinc-800">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-green-400" />
<span className="text-sm font-medium text-zinc-300">
One-Click Install
<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">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
{/* Left side - Instructions */}
<div className="flex-shrink-0 lg:w-auto">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-green-500/20">
<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 terminal</p>
</div>
</div>
</div>
{/* Right side - Command */}
<div className="flex-1 min-w-0">
<div className="relative group">
<div className="bg-zinc-950/80 rounded-xl border border-zinc-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<div className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
</div>
<CopyButton text={oneClickInstallCommand} />
</div>
<div className="p-4 overflow-x-auto">
<code className="font-mono text-sm sm:text-base text-green-400 whitespace-nowrap flex items-center gap-2">
<span className="text-zinc-500 select-none">$</span>
<span>{oneClickInstallCommand}</span>
</code>
</div>
</div>
</div>
</div>
</div>
{/* Bottom info */}
<div className="flex flex-wrap items-center justify-between gap-4 mt-6 pt-6 border-t border-zinc-800/50">
<p className="text-sm text-zinc-400">
<span className="text-zinc-500">Compatible:</span> Ubuntu 22.04+, Fedora, Debian, Arch, and most Linux distros
</p>
<div className="flex items-center gap-4 text-xs text-zinc-500">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
No sudo required
</span>
<span className="flex items-center gap-1">
<Zap className="h-3.5 w-3.5 text-yellow-500" />
~5-10 min install
</span>
</div>
<CopyButton text={oneClickInstallCommand} />
</div>
<div className="bg-zinc-950 rounded-lg p-4 font-mono text-sm sm:text-base text-green-400 overflow-x-auto">
<span className="text-zinc-500">$</span> {oneClickInstallCommand}
</div>
<p className="text-xs text-zinc-500 mt-3 text-center">
Works on Ubuntu 22.04+, Fedora, Debian, and most Linux distros. Takes ~5-10 min.
</p>
</div>
</motion.div>
@@ -365,54 +412,22 @@ export default function HomePage() {
</div>
</section>
{/* Demo Video Section */}
{/* 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">
<FadeInSection>
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold mb-4">
See It In Action
Try It Yourself
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Double-tap Ctrl, speak naturally, and watch your words appear instantly. It&apos;s that simple.
Experience voice-to-text right here in your browser. Click the microphone and start speaking!
</p>
</div>
</FadeInSection>
<FadeInSection delay={0.1}>
<div className="relative aspect-video rounded-2xl overflow-hidden shadow-2xl bg-zinc-900">
{/* Placeholder for demo video - using animated mockup */}
<div className="absolute inset-0 flex flex-col">
{/* Terminal-like header */}
<div className="bg-zinc-800 px-4 py-3 flex items-center gap-2">
<div className="flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-yellow-500" />
<div className="h-3 w-3 rounded-full bg-green-500" />
</div>
<span className="text-sm text-zinc-400 ml-4">Vocalinux Demo</span>
</div>
{/* Demo content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<motion.div
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="inline-flex items-center justify-center h-24 w-24 rounded-full bg-primary/20 mb-6"
>
<Mic className="h-12 w-12 text-primary" />
</motion.div>
<p className="text-zinc-300 text-lg mb-4">
Press <kbd className="px-2 py-1 bg-zinc-700 rounded text-sm mx-1">Ctrl</kbd> twice to start dictating
</p>
<p className="text-zinc-500 text-sm">
Try it right now! This page has the demo built-in.
</p>
</div>
</div>
</div>
</div>
<LiveDemo />
</FadeInSection>
{/* Key demo points */}
@@ -422,7 +437,7 @@ export default function HomePage() {
{
icon: <Keyboard className="h-6 w-6 text-primary" />,
title: "Double-tap Ctrl",
description: "Simple, universal activation that works in any app",
description: "In the real app, just double-tap Ctrl to start dictating anywhere",
},
{
icon: <Zap className="h-6 w-6 text-primary" />,
@@ -529,7 +544,7 @@ export default function HomePage() {
<div className="relative">
<div className="bg-white dark:bg-zinc-800 rounded-2xl p-8 shadow-xl">
<div className="flex items-center gap-4 mb-6">
<VocalinuxLogo className="h-16 w-16 text-primary" />
<Image src="/vocalinux.svg" alt="Vocalinux" width={64} height={64} className="h-16 w-16" />
<div>
<div className="text-2xl font-bold">Vocalinux</div>
<div className="text-sm text-muted-foreground">
@@ -958,7 +973,7 @@ export default function HomePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 mb-12">
<div className="md:col-span-2">
<a href="/" className="flex items-center gap-2 mb-4">
<VocalinuxLogo className="h-8 w-8 text-primary" />
<Image src="/vocalinux.svg" alt="Vocalinux" width={32} height={32} className="h-8 w-8" />
<span className="text-xl font-bold">Vocalinux</span>
</a>
<p className="text-zinc-400 mb-4 max-w-md">
+22 -3
View File
@@ -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>
);
};
+323
View File
@@ -0,0 +1,323 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Mic, MicOff, AlertCircle, Chrome, Globe, Volume2, Sparkles } 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 recognitionRef = useRef<SpeechRecognition | null>(null);
// 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();
}
}, []);
const clearTranscript = useCallback(() => {
setTranscript("");
setInterimTranscript("");
}, []);
// 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>
</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&apos;s address bar to enable microphone access.
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Microphone button */}
<div className="flex flex-col items-center mb-6">
<motion.button
onClick={isListening ? stopListening : startListening}
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>
<p className="text-sm text-zinc-400 mt-4">
{isListening ? (
<span className="flex items-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... Click to stop
</span>
) : (
"Click the microphone to try voice recognition"
)}
</p>
</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>
);
}
+1 -1
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 text-primary", iconClassName)} />
<VocalinuxLogo className={cn("h-8 w-8", iconClassName)} />
<span className={cn("font-bold text-xl", textClassName)}>Vocalinux</span>
</div>;
}