summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components')
-rw-r--r--makima/frontend/src/components/GridOverlay.tsx3
-rw-r--r--makima/frontend/src/components/Logo.tsx130
-rw-r--r--makima/frontend/src/components/Masthead.tsx44
-rw-r--r--makima/frontend/src/components/NavStrip.tsx40
-rw-r--r--makima/frontend/src/components/RewriteLink.tsx63
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx143
-rw-r--r--makima/frontend/src/components/listen/SpeakerPanel.tsx61
-rw-r--r--makima/frontend/src/components/listen/TranscriptPanel.tsx85
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>
+ );
+}