diff options
Diffstat (limited to 'makima/frontend/src/components')
| -rw-r--r-- | makima/frontend/src/components/GridOverlay.tsx | 3 | ||||
| -rw-r--r-- | makima/frontend/src/components/Logo.tsx | 130 | ||||
| -rw-r--r-- | makima/frontend/src/components/Masthead.tsx | 44 | ||||
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 40 | ||||
| -rw-r--r-- | makima/frontend/src/components/RewriteLink.tsx | 63 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/ControlPanel.tsx | 143 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/SpeakerPanel.tsx | 61 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/TranscriptPanel.tsx | 85 |
8 files changed, 569 insertions, 0 deletions
diff --git a/makima/frontend/src/components/GridOverlay.tsx b/makima/frontend/src/components/GridOverlay.tsx new file mode 100644 index 0000000..6728149 --- /dev/null +++ b/makima/frontend/src/components/GridOverlay.tsx @@ -0,0 +1,3 @@ +export function GridOverlay() { + return <div className="grid-overlay" aria-hidden="true" />; +} diff --git a/makima/frontend/src/components/Logo.tsx b/makima/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..5cbde9f --- /dev/null +++ b/makima/frontend/src/components/Logo.tsx @@ -0,0 +1,130 @@ +interface LogoProps { + size?: number; + listening?: boolean; + onClick?: () => void; + className?: string; + noHoverAnimation?: boolean; +} + +export function Logo({ + size = 160, + listening = false, + onClick, + className = "", + noHoverAnimation = false, +}: LogoProps) { + const shellSize = size * 1.4375; // 230/160 ratio + const haloSize = size * 1.3125; // 210/160 ratio + + return ( + <div + className={`relative grid place-items-center ${className}`} + style={{ + width: shellSize, + height: shellSize, + filter: "drop-shadow(0 10px 26px rgba(12, 35, 67, 0.32))", + }} + > + <div + className={`logo-shell ${listening ? "listening" : ""} ${noHoverAnimation ? "no-hover-animation" : ""} ${onClick ? "cursor-pointer" : ""}`} + style={{ width: shellSize, height: shellSize }} + onClick={onClick} + role={onClick ? "button" : undefined} + tabIndex={onClick ? 0 : undefined} + onKeyDown={ + onClick + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + } + : undefined + } + > + <span className="scan-sweep" /> + <span className="scan-sweep sweep-2" /> + <svg + className="logo-svg" + viewBox="0 0 120 120" + xmlns="http://www.w3.org/2000/svg" + style={{ width: size, height: size }} + role="img" + aria-label="Makima logo" + > + <circle + className="ring ring-outer" + cx="60" + cy="60" + r="52" + strokeWidth="4" + /> + <circle + className="ring ring-middle" + cx="60" + cy="60" + r="36" + strokeWidth="3" + /> + <circle + className="ring ring-inner" + cx="60" + cy="60" + r="22" + strokeWidth="3" + /> + <circle className="core" cx="60" cy="60" r="8" /> + </svg> + </div> + <div + className="halo" + aria-hidden="true" + style={{ width: haloSize, height: haloSize }} + /> + </div> + ); +} + +// Small logo for header +export function LogoMark({ size = 32 }: { size?: number }) { + return ( + <span + className="inline-flex items-center justify-center" + style={{ width: size, height: size }} + aria-hidden="true" + > + <svg + width={size} + height={size} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <circle + cx="12" + cy="12" + r="10" + fill="none" + stroke="#0f3c78" + strokeWidth="2" + /> + <circle + cx="12" + cy="12" + r="7" + fill="none" + stroke="#0f3c78" + strokeWidth="1.6" + /> + <circle + cx="12" + cy="12" + r="4" + fill="none" + stroke="#0f3c78" + strokeWidth="1.6" + /> + <circle cx="12" cy="12" r="1.6" fill="#0f3c78" /> + </svg> + </span> + ); +} diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx new file mode 100644 index 0000000..a89977f --- /dev/null +++ b/makima/frontend/src/components/Masthead.tsx @@ -0,0 +1,44 @@ +import { Link } from "react-router"; +import { LogoMark } from "./Logo"; +import { NavStrip } from "./NavStrip"; + +interface MastheadProps { + showTicker?: boolean; + showNav?: boolean; +} + +export function Masthead({ showTicker = false, showNav = true }: MastheadProps) { + return ( + <header className="border-b-4 border-double border-[#050d1f] bg-[#08162e]"> + <div className="flex items-center gap-3 px-4 py-3"> + <Link to="/" className="flex items-center gap-3 no-underline"> + <LogoMark size={32} /> + <div> + <h1 className="m-0 text-xl text-white tracking-widest font-normal"> + makima.jp + </h1> + <small className="block text-[#dbe7ff] text-xs tracking-wide"> + Real-time Speech Recognition + </small> + </div> + </Link> + </div> + + {showTicker && ( + <div className="relative overflow-hidden border border-[#153667] bg-[#0a2242] text-[#b9d4ff] font-mono text-xs px-2.5 py-2 mx-4 mb-3"> + <div className="absolute inset-y-0 left-0 w-3 bg-gradient-to-b from-[rgba(231,237,247,0.5)] to-transparent" /> + <div className="absolute inset-y-0 right-0 w-3 bg-gradient-to-b from-[rgba(231,237,247,0.5)] to-transparent rotate-180" /> + <span className="ticker-content"> + /// MAKIMA INFORMATION SERVICE // REAL-TIME STT PLATFORM /// + TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE /// + MAKIMA.JP /// MAKIMA INFORMATION SERVICE // REAL-TIME STT PLATFORM + /// TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE /// + MAKIMA.JP /// + </span> + </div> + )} + + {showNav && <NavStrip />} + </header> + ); +} diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx new file mode 100644 index 0000000..875af5a --- /dev/null +++ b/makima/frontend/src/components/NavStrip.tsx @@ -0,0 +1,40 @@ +import { RewriteLink } from "./RewriteLink"; + +interface NavLink { + label: string; + href: string; + disabled?: boolean; + external?: boolean; +} + +const NAV_LINKS: NavLink[] = [ + { label: "Listen", href: "/listen" }, + { label: "Mesh", href: "/mesh", disabled: true }, + { label: "Register", href: "/register", disabled: true }, + { label: "Login", href: "/login", disabled: true }, +]; + +export function NavStrip() { + return ( + <nav + className="flex items-center gap-2.5 px-3 py-2.5 border-t border-b border-dashed border-[rgba(117,170,252,0.35)] bg-[#0c1729] font-mono uppercase tracking-wide text-[11px]" + aria-label="Main navigation" + > + <span className="text-[#9bc3ff] pr-2.5 border-r border-[rgba(117,170,252,0.35)]"> + NAV// + </span> + <div className="flex flex-wrap gap-2 items-center"> + {NAV_LINKS.map((link) => ( + <RewriteLink + key={link.label} + to={link.href} + disabled={link.disabled} + external={link.external} + > + {link.label} + </RewriteLink> + ))} + </div> + </nav> + ); +} diff --git a/makima/frontend/src/components/RewriteLink.tsx b/makima/frontend/src/components/RewriteLink.tsx new file mode 100644 index 0000000..6e591a1 --- /dev/null +++ b/makima/frontend/src/components/RewriteLink.tsx @@ -0,0 +1,63 @@ +import { Link } from "react-router"; +import { useTextScramble } from "../hooks/useTextScramble"; + +interface RewriteLinkProps { + to?: string; + href?: string; + children: string; + disabled?: boolean; + external?: boolean; + className?: string; +} + +export function RewriteLink({ + to, + href, + children, + disabled = false, + external = false, + className = "", +}: RewriteLinkProps) { + const { displayText, scramble, reset } = useTextScramble(children); + + const baseClass = `rewrite-link ${className}`; + + if (disabled) { + return ( + <span + className={baseClass} + aria-disabled="true" + onMouseEnter={scramble} + onMouseLeave={reset} + > + {displayText} + </span> + ); + } + + if (external || href) { + return ( + <a + href={href || to} + target="_blank" + rel="noopener noreferrer" + className={baseClass} + onMouseEnter={scramble} + onMouseLeave={reset} + > + {displayText} + </a> + ); + } + + return ( + <Link + to={to || "/"} + className={baseClass} + onMouseEnter={scramble} + onMouseLeave={reset} + > + {displayText} + </Link> + ); +} diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx new file mode 100644 index 0000000..4d86850 --- /dev/null +++ b/makima/frontend/src/components/listen/ControlPanel.tsx @@ -0,0 +1,143 @@ +import { Logo } from "../Logo"; +import type { MicrophoneStatus } from "../../hooks/useMicrophone"; + +interface ControlPanelProps { + isListening: boolean; + isConnected: boolean; + micStatus: MicrophoneStatus; + micVolume: number; + onToggle: () => void; + onReset: () => void; + error?: string | null; +} + +function getStatusText(isListening: boolean, micStatus: MicrophoneStatus): string { + if (isListening) return "Listening..."; + + switch (micStatus) { + case "requesting": + return "Requesting permission..."; + case "ready": + return "Click to start"; + case "denied": + return "Permission denied - click to retry"; + case "error": + return "Error - click to retry"; + default: + return "Click to start"; + } +} + +export function ControlPanel({ + isListening, + isConnected, + micStatus, + micVolume, + onToggle, + onReset, + error, +}: ControlPanelProps) { + const statusText = getStatusText(isListening, micStatus); + const isRequesting = micStatus === "requesting"; + + return ( + <div className="panel h-full p-4 flex flex-col items-center justify-center gap-4"> + {/* Logo button */} + <div className="flex flex-col items-center gap-2"> + <Logo + size={100} + listening={isListening || isRequesting} + onClick={isRequesting ? undefined : onToggle} + className={isRequesting ? "opacity-50" : "cursor-pointer"} + noHoverAnimation + /> + <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase text-center"> + {statusText} + </span> + </div> + + {/* Status indicators */} + <div className="font-mono text-xs text-center flex flex-col gap-1"> + {/* Microphone status */} + <div + className={`inline-flex flex-col gap-1 px-2 py-1 border ${ + micStatus === "ready" || micStatus === "recording" + ? "border-[#3f6fb3] text-[#75aafc]" + : micStatus === "denied" || micStatus === "error" + ? "border-red-400/50 text-red-400" + : "border-[rgba(117,170,252,0.25)] text-[#9bc3ff]" + }`} + > + <div className="inline-flex items-center gap-1.5"> + <span + className={`w-1.5 h-1.5 rounded-full ${ + micStatus === "ready" || micStatus === "recording" + ? "bg-[#75aafc]" + : micStatus === "denied" || micStatus === "error" + ? "bg-red-400" + : "bg-[#3f6fb3]" + }`} + /> + {micStatus === "ready" || micStatus === "recording" + ? "MIC READY" + : micStatus === "requesting" + ? "REQUESTING..." + : micStatus === "denied" + ? "MIC DENIED" + : micStatus === "error" + ? "MIC ERROR" + : "MIC IDLE"} + </div> + {isListening && ( + <div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden"> + <div + className="h-full bg-[#75aafc] transition-all duration-75" + style={{ width: `${micVolume * 100}%` }} + /> + </div> + )} + </div> + + {/* Connection status */} + <div + className={`inline-flex items-center gap-1.5 px-2 py-1 border ${ + isConnected + ? "border-[#3f6fb3] text-[#75aafc]" + : "border-[rgba(117,170,252,0.25)] text-[#9bc3ff]" + }`} + > + <span + className={`w-1.5 h-1.5 rounded-full ${ + isConnected ? "bg-[#75aafc]" : "bg-[#3f6fb3]" + }`} + /> + {isConnected ? "CONNECTED" : "DISCONNECTED"} + </div> + </div> + + {/* Error display */} + {error && ( + <div className="font-mono text-xs text-red-400 text-center px-2 py-1 border border-red-400/50 bg-red-400/10 max-w-[250px]"> + {error} + </div> + )} + + {/* Buttons */} + <div className="flex gap-2 mt-2"> + <button + onClick={onReset} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide" + > + Reset + </button> + <button + disabled + className="px-3 py-1.5 font-mono text-xs text-[#9aa9c6] bg-[#0b1423] border border-[rgba(117,170,252,0.25)] cursor-not-allowed uppercase tracking-wide opacity-50" + title="File upload coming soon" + > + Upload + </button> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/listen/SpeakerPanel.tsx b/makima/frontend/src/components/listen/SpeakerPanel.tsx new file mode 100644 index 0000000..cb43992 --- /dev/null +++ b/makima/frontend/src/components/listen/SpeakerPanel.tsx @@ -0,0 +1,61 @@ +interface Speaker { + id: string; + label: string; + isActive: boolean; +} + +interface SpeakerPanelProps { + speakers: Speaker[]; +} + +const SPEAKER_SYMBOLS = ["///", ":::", "***", "###", "+++", "---", "===", "%%%"]; + +export function SpeakerPanel({ speakers }: SpeakerPanelProps) { + return ( + <div className="panel h-full p-4 flex flex-col"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-3 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + SPEAKERS// + </div> + + {speakers.length === 0 ? ( + <div className="flex-1 flex items-center justify-center text-[#9bc3ff] text-sm font-mono opacity-60"> + <span>Waiting for speech...</span> + </div> + ) : ( + <div className="flex-1 flex flex-col gap-3"> + {speakers.map((speaker, index) => ( + <div + key={speaker.id} + className={`flex items-center gap-3 p-3 border ${ + speaker.isActive + ? "border-[#3f6fb3] bg-[#0f1c2f]" + : "border-[rgba(117,170,252,0.25)] bg-[#0b1423]" + } transition-colors`} + > + <span + className={`font-mono text-2xl tracking-tighter ${ + speaker.isActive + ? "text-[#75aafc] animate-pulse" + : "text-[#3f6fb3]" + }`} + > + {SPEAKER_SYMBOLS[index % SPEAKER_SYMBOLS.length]} + </span> + <div className="flex-1"> + <div className="font-mono text-sm text-[#dbe7ff]"> + {speaker.label} + </div> + <div className="font-mono text-xs text-[#9bc3ff]"> + {speaker.isActive ? "speaking" : "idle"} + </div> + </div> + {speaker.isActive && ( + <div className="w-2 h-2 rounded-full bg-[#75aafc] animate-pulse" /> + )} + </div> + ))} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/listen/TranscriptPanel.tsx b/makima/frontend/src/components/listen/TranscriptPanel.tsx new file mode 100644 index 0000000..662c94f --- /dev/null +++ b/makima/frontend/src/components/listen/TranscriptPanel.tsx @@ -0,0 +1,85 @@ +import { useRef, useEffect, useState, useCallback } from "react"; +import type { TranscriptEntry } from "../../types/messages"; + +interface TranscriptPanelProps { + transcripts: TranscriptEntry[]; +} + +export function TranscriptPanel({ transcripts }: TranscriptPanelProps) { + const containerRef = useRef<HTMLDivElement>(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Auto-scroll when new transcripts arrive + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [transcripts, autoScroll]); + + // Detect manual scroll + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + + setAutoScroll(isAtBottom); + }, []); + + const scrollToBottom = useCallback(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + setAutoScroll(true); + } + }, []); + + return ( + <div className="panel h-full flex flex-col"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)] flex justify-between items-center"> + <span>TRANSCRIPT//</span> + {!autoScroll && ( + <button + onClick={scrollToBottom} + className="px-2 py-1 text-[10px] bg-[#0f1c2f] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + Scroll to bottom + </button> + )} + </div> + + <div + ref={containerRef} + onScroll={handleScroll} + className="flex-1 overflow-y-auto p-4 space-y-3" + > + {transcripts.length === 0 ? ( + <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> + Transcriptions will appear here... + </div> + ) : ( + transcripts.map((entry) => ( + <div + key={entry.id} + className={`font-mono text-sm ${ + entry.isFinal ? "opacity-100" : "opacity-70" + }`} + > + <div className="flex items-baseline gap-2 mb-1"> + <span className="text-[#75aafc] text-xs"> + [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s] + </span> + <span className="text-[#9bc3ff] text-xs font-bold"> + {entry.speaker} + </span> + {entry.isFinal && ( + <span className="text-[#3f6fb3] text-[10px]">[FINAL]</span> + )} + </div> + <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p> + </div> + )) + )} + </div> + </div> + ); +} |
