diff --git a/app/epd/swift/page.tsx b/app/epd/swift/page.tsx index 005bde7..5f88b50 100644 --- a/app/epd/swift/page.tsx +++ b/app/epd/swift/page.tsx @@ -6,24 +6,8 @@ * Main entry point for Swift - the Contextual UI EPD. */ -import { useSwiftStore } from '@/stores/swift-store'; import { CommandCenter } from '@/components/swift'; export default function SwiftPage() { - const { activeBlock } = useSwiftStore(); - - return ( - - {activeBlock ? ( -
Block: {activeBlock}
- ) : ( -
-

Wat wil je doen?

-

- Typ of spreek je intentie, bijvoorbeeld: "notitie jan medicatie" -

-
- )} -
- ); + return ; } diff --git a/components/swift/command-center/canvas-area.tsx b/components/swift/command-center/canvas-area.tsx new file mode 100644 index 0000000..e099710 --- /dev/null +++ b/components/swift/command-center/canvas-area.tsx @@ -0,0 +1,74 @@ +'use client'; + +/** + * Canvas Area + * + * Central area where blocks appear. Shows empty state when no block is active. + */ + +import { useSwiftStore } from '@/stores/swift-store'; + +export function CanvasArea() { + const { activeBlock } = useSwiftStore(); + + return ( +
+ {activeBlock ? ( + // Block will be rendered here by the page component +
Block: {activeBlock}
+ ) : ( + + )} +
+ ); +} + +function EmptyState() { + return ( +
+
+
+ + + + + +
+

Wat wil je doen?

+

+ Typ of spreek je intentie +

+
+ +
+ + + +
+ +

+ ⌘K om te focussen +

+
+ ); +} + +function ExampleCommand({ icon, text }: { icon: string; text: string }) { + return ( +
+ {icon} + "{text}" +
+ ); +} diff --git a/components/swift/command-center/command-center.tsx b/components/swift/command-center/command-center.tsx index 6fc88f1..1a1d3f2 100644 --- a/components/swift/command-center/command-center.tsx +++ b/components/swift/command-center/command-center.tsx @@ -4,27 +4,62 @@ * Command Center * * Main container for the Swift interface. - * Orchestrates Context Bar, Canvas, Recent Strip, and Command Input. + * 4-zone layout: Context Bar | Canvas Area | Recent Strip | Command Input + * + * Layout specs: + * - Context Bar: 48px (h-12) + * - Canvas Area: flex-1 (fills remaining space) + * - Recent Strip: 48px (h-12) + * - Command Input: 64px (h-16) */ -import { ReactNode } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; +import { useSwiftStore } from '@/stores/swift-store'; import { ContextBar } from './context-bar'; import { CommandInput } from './command-input'; import { RecentStrip } from './recent-strip'; +import { CanvasArea } from './canvas-area'; -interface CommandCenterProps { - children?: ReactNode; -} +export function CommandCenter() { + const { closeBlock, activeBlock } = useSwiftStore(); + const inputRef = useRef(null); + + // Global keyboard shortcuts + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Escape: close active block + if (e.key === 'Escape' && activeBlock) { + e.preventDefault(); + closeBlock(); + } + + // Cmd/Ctrl + K: focus input + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + } + }, + [activeBlock, closeBlock] + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); -export function CommandCenter({ children }: CommandCenterProps) { return ( <> + {/* Context Bar - 48px */} -
- {children} -
+ + {/* Canvas Area - flex */} + + + {/* Recent Strip - 48px */} - + + {/* Command Input - 64px */} + ); } diff --git a/components/swift/command-center/command-input.tsx b/components/swift/command-center/command-input.tsx index 2aec289..1a7bd9e 100644 --- a/components/swift/command-center/command-input.tsx +++ b/components/swift/command-center/command-input.tsx @@ -4,44 +4,234 @@ * Command Input * * Bottom input bar for text and voice commands. + * Height: 64px (h-16) + * + * Features: + * - Text input with dynamic placeholder + * - Focus state with ring + * - Send button (appears when input has value) + * - Voice input with Deepgram streaming + * - ⌘K shortcut hint */ +import { forwardRef, useState, useEffect, useRef } from 'react'; import { useSwiftStore } from '@/stores/swift-store'; -import { Mic } from 'lucide-react'; +import { useSwiftVoice } from '@/lib/swift/use-swift-voice'; +import { Mic, MicOff, Send, Loader2 } from 'lucide-react'; -export function CommandInput() { - const { inputValue, setInputValue, isVoiceActive, setVoiceActive } = useSwiftStore(); +export const CommandInput = forwardRef(function CommandInput(_, ref) { + const { + inputValue, + setInputValue, + clearInput, + activePatient, + activeBlock, + isVoiceActive, + } = useSwiftStore(); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - // TODO: Process intent (E2) - console.log('Submit:', inputValue); + const { + isRecording, + isConnecting, + isConnected, + error: voiceError, + startRecording, + stopRecording, + analyserNode, + isBrowserSupported, + } = useSwiftVoice(); + + const [isProcessing, setIsProcessing] = useState(false); + const waveformRef = useRef(null); + const animationRef = useRef(null); + + const hasValue = inputValue.trim().length > 0; + + // Waveform visualization + useEffect(() => { + if (!analyserNode || !waveformRef.current || !isRecording) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + return; + } + + const canvas = waveformRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const bufferLength = analyserNode.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + const draw = () => { + if (!isRecording) return; + + animationRef.current = requestAnimationFrame(draw); + analyserNode.getByteFrequencyData(dataArray); + + ctx.fillStyle = 'rgb(30, 41, 59)'; // slate-800 + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const barWidth = (canvas.width / bufferLength) * 2.5; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + const barHeight = (dataArray[i] / 255) * canvas.height; + + // Gradient from blue to red based on amplitude + const hue = 220 - (dataArray[i] / 255) * 40; + ctx.fillStyle = `hsl(${hue}, 70%, 60%)`; + + ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); + x += barWidth + 1; + } + }; + + draw(); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [analyserNode, isRecording]); + + // Dynamic placeholder based on context + const getPlaceholder = () => { + if (isRecording) return 'Luisteren...'; + if (isConnecting) return 'Verbinden met spraakherkenning...'; + if (activePatient) { + return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`; + } + return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")'; }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!hasValue || isProcessing) return; + + // Stop recording if active + if (isRecording) { + stopRecording(); + } + + setIsProcessing(true); + try { + // TODO: Process intent (E2) + console.log('Submit:', inputValue); + clearInput(); + } finally { + setIsProcessing(false); + } + }; + + const handleVoiceToggle = () => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }; + + const isDisabled = isProcessing || activeBlock !== null; + return ( -