diff options
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 143 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileList.tsx | 96 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/ControlPanel.tsx | 11 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useFiles.ts | 105 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 102 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 3 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 95 | ||||
| -rw-r--r-- | makima/frontend/src/routes/listen.tsx | 8 |
9 files changed, 557 insertions, 7 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 875af5a..4e90d4d 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -9,6 +9,7 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, + { label: "Files", href: "/files" }, { label: "Mesh", href: "/mesh", disabled: true }, { label: "Register", href: "/register", disabled: true }, { label: "Login", href: "/login", disabled: true }, diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx new file mode 100644 index 0000000..643f35e --- /dev/null +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import type { FileDetail as FileDetailType } from "../../lib/api"; + +interface FileDetailProps { + file: FileDetailType; + loading: boolean; + onBack: () => void; + onSave: (id: string, name: string, description: string) => void; + onDelete: (id: string) => void; +} + +export function FileDetail({ + file, + loading, + onBack, + onSave, + onDelete, +}: FileDetailProps) { + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(file.name); + const [description, setDescription] = useState(file.description || ""); + + const handleSave = () => { + onSave(file.id, name, description); + setIsEditing(false); + }; + + const handleCancel = () => { + setName(file.name); + setDescription(file.description || ""); + setIsEditing(false); + }; + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ); + } + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="flex items-center justify-between mb-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + ← Back to list + </button> + <div className="flex gap-2"> + {isEditing ? ( + <> + <button + onClick={handleCancel} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Cancel + </button> + <button + onClick={handleSave} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + Save + </button> + </> + ) : ( + <> + <button + onClick={() => setIsEditing(true)} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase" + > + Edit + </button> + <button + onClick={() => onDelete(file.id)} + className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + </> + )} + </div> + </div> + + {isEditing ? ( + <div className="space-y-3"> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + placeholder="File name" + /> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + rows={2} + placeholder="Description (optional)" + /> + </div> + ) : ( + <> + <h2 className="font-mono text-lg text-[#dbe7ff] mb-1"> + {file.name} + </h2> + {file.description && ( + <p className="font-mono text-sm text-[#9bc3ff]"> + {file.description} + </p> + )} + </> + )} + </div> + + {/* Transcript */} + <div className="flex-1 overflow-y-auto p-4 space-y-3"> + {file.transcript.length === 0 ? ( + <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> + No transcript entries. + </div> + ) : ( + file.transcript.map((entry) => ( + <div key={entry.id} className="font-mono text-sm"> + <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> + </div> + <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p> + </div> + )) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx new file mode 100644 index 0000000..7e1eea4 --- /dev/null +++ b/makima/frontend/src/components/files/FileList.tsx @@ -0,0 +1,96 @@ +import type { FileSummary } from "../../lib/api"; + +interface FileListProps { + files: FileSummary[]; + loading: boolean; + onSelect: (id: string) => void; + onDelete: (id: string) => void; +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return "-"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function FileList({ + files, + loading, + onSelect, + onDelete, +}: FileListProps) { + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading files...</div> + </div> + ); + } + + 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)]"> + FILES// + </div> + + <div className="flex-1 overflow-y-auto"> + {files.length === 0 ? ( + <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> + No saved files yet. Start recording to create one. + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.15)]"> + {files.map((file) => ( + <div + key={file.id} + className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors" + > + <div className="flex items-start justify-between gap-4"> + <button + onClick={() => onSelect(file.id)} + className="flex-1 text-left" + > + <h3 className="font-mono text-sm text-[#dbe7ff] mb-1"> + {file.name} + </h3> + {file.description && ( + <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2"> + {file.description} + </p> + )} + <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> + <span>{file.transcriptCount} segments</span> + <span>{formatDuration(file.duration)}</span> + <span>{formatDate(file.createdAt)}</span> + </div> + </button> + <button + onClick={(e) => { + e.stopPropagation(); + onDelete(file.id); + }} + className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + </div> + </div> + ))} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx index 25dbefe..af2cd05 100644 --- a/makima/frontend/src/components/listen/ControlPanel.tsx +++ b/makima/frontend/src/components/listen/ControlPanel.tsx @@ -6,8 +6,9 @@ interface ControlPanelProps { isConnected: boolean; micStatus: MicrophoneStatus; micVolume: number; + hasTranscripts: boolean; onToggle: () => void; - onReset: () => void; + onNew: () => void; error?: string | null; } @@ -33,8 +34,9 @@ export function ControlPanel({ isConnected, micStatus, micVolume, + hasTranscripts, onToggle, - onReset, + onNew, error, }: ControlPanelProps) { const statusText = getStatusText(isListening, micStatus); @@ -125,10 +127,11 @@ export function ControlPanel({ {/* Buttons */} <div className="flex gap-2"> <button - onClick={onReset} + onClick={onNew} 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" + title={hasTranscripts ? "Save and start new session" : "Start new session"} > - Reset + New </button> <button disabled diff --git a/makima/frontend/src/hooks/useFiles.ts b/makima/frontend/src/hooks/useFiles.ts new file mode 100644 index 0000000..aacbb6a --- /dev/null +++ b/makima/frontend/src/hooks/useFiles.ts @@ -0,0 +1,105 @@ +import { useState, useCallback, useEffect } from "react"; +import { + listFiles, + getFile, + createFile, + updateFile, + deleteFile, + type FileSummary, + type FileDetail, + type CreateFileRequest, + type UpdateFileRequest, +} from "../lib/api"; + +export function useFiles() { + const [files, setFiles] = useState<FileSummary[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchFiles = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await listFiles(); + setFiles(response.files); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch files"); + } finally { + setLoading(false); + } + }, []); + + const fetchFile = useCallback( + async (id: string): Promise<FileDetail | null> => { + setError(null); + try { + return await getFile(id); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch file"); + return null; + } + }, + [] + ); + + const saveFile = useCallback( + async (data: CreateFileRequest): Promise<FileDetail | null> => { + setError(null); + try { + const file = await createFile(data); + await fetchFiles(); // Refresh list + return file; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save file"); + return null; + } + }, + [fetchFiles] + ); + + const editFile = useCallback( + async (id: string, data: UpdateFileRequest): Promise<FileDetail | null> => { + setError(null); + try { + const file = await updateFile(id, data); + await fetchFiles(); // Refresh list + return file; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update file"); + return null; + } + }, + [fetchFiles] + ); + + const removeFile = useCallback( + async (id: string): Promise<boolean> => { + setError(null); + try { + await deleteFile(id); + await fetchFiles(); // Refresh list + return true; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete file"); + return false; + } + }, + [fetchFiles] + ); + + // Initial fetch + useEffect(() => { + fetchFiles(); + }, [fetchFiles]); + + return { + files, + loading, + error, + fetchFiles, + fetchFile, + saveFile, + editFile, + removeFile, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index a6f6c3e..ec596ce 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -38,3 +38,105 @@ export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`; export function getEnvironment(): Environment { return env; } + +// File API types +export interface TranscriptEntry { + id: string; + speaker: string; + start: number; + end: number; + text: string; + isFinal: boolean; +} + +export interface FileSummary { + id: string; + name: string; + description: string | null; + transcriptCount: number; + duration: number | null; + createdAt: string; + updatedAt: string; +} + +export interface FileDetail { + id: string; + ownerId: string; + name: string; + description: string | null; + transcript: TranscriptEntry[]; + location: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FileListResponse { + files: FileSummary[]; + total: number; +} + +export interface CreateFileRequest { + name?: string; + description?: string; + transcript: TranscriptEntry[]; + location?: string; +} + +export interface UpdateFileRequest { + name?: string; + description?: string; + transcript?: TranscriptEntry[]; +} + +// File API functions +export async function listFiles(): Promise<FileListResponse> { + const res = await fetch(`${API_BASE}/api/v1/files`); + if (!res.ok) { + throw new Error(`Failed to list files: ${res.statusText}`); + } + return res.json(); +} + +export async function getFile(id: string): Promise<FileDetail> { + const res = await fetch(`${API_BASE}/api/v1/files/${id}`); + if (!res.ok) { + throw new Error(`Failed to get file: ${res.statusText}`); + } + return res.json(); +} + +export async function createFile(data: CreateFileRequest): Promise<FileDetail> { + const res = await fetch(`${API_BASE}/api/v1/files`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) { + throw new Error(`Failed to create file: ${res.statusText}`); + } + return res.json(); +} + +export async function updateFile( + id: string, + data: UpdateFileRequest +): Promise<FileDetail> { + const res = await fetch(`${API_BASE}/api/v1/files/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) { + throw new Error(`Failed to update file: ${res.statusText}`); + } + return res.json(); +} + +export async function deleteFile(id: string): Promise<void> { + const res = await fetch(`${API_BASE}/api/v1/files/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to delete file: ${res.statusText}`); + } +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index fe5be21..874ab1a 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -5,6 +5,7 @@ import "./index.css"; import { GridOverlay } from "./components/GridOverlay"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; +import FilesPage from "./routes/files"; createRoot(document.getElementById("root")!).render( <StrictMode> @@ -13,6 +14,8 @@ createRoot(document.getElementById("root")!).render( <Routes> <Route path="/" element={<HomePage />} /> <Route path="/listen" element={<ListenPage />} /> + <Route path="/files" element={<FilesPage />} /> + <Route path="/files/:id" element={<FilesPage />} /> </Routes> </BrowserRouter> </StrictMode> diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx new file mode 100644 index 0000000..86a24b8 --- /dev/null +++ b/makima/frontend/src/routes/files.tsx @@ -0,0 +1,95 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { FileList } from "../components/files/FileList"; +import { FileDetail } from "../components/files/FileDetail"; +import { useFiles } from "../hooks/useFiles"; +import type { FileDetail as FileDetailType } from "../lib/api"; + +export default function FilesPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { files, loading, error, fetchFile, editFile, removeFile } = useFiles(); + const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + + // Load file detail when URL has an id + useEffect(() => { + if (id) { + setDetailLoading(true); + fetchFile(id).then((detail) => { + setFileDetail(detail); + setDetailLoading(false); + }); + } else { + setFileDetail(null); + } + }, [id, fetchFile]); + + const handleSelectFile = useCallback( + (fileId: string) => { + navigate(`/files/${fileId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/files"); + }, [navigate]); + + const handleDelete = useCallback( + async (fileId: string) => { + if (confirm("Are you sure you want to delete this file?")) { + const success = await removeFile(fileId); + if (success && id === fileId) { + navigate("/files"); + } + } + }, + [removeFile, id, navigate] + ); + + const handleSave = useCallback( + async (fileId: string, name: string, description: string) => { + await editFile(fileId, { name, description }); + const detail = await fetchFile(fileId); + setFileDetail(detail); + }, + [editFile, fetchFile] + ); + + return ( + <div className="relative z-10 h-screen flex flex-col overflow-hidden"> + <Masthead showTicker={false} showNav /> + + <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden"> + {error && ( + <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {id && fileDetail ? ( + <FileDetail + file={fileDetail} + loading={detailLoading} + onBack={handleBack} + onSave={handleSave} + onDelete={handleDelete} + /> + ) : id && detailLoading ? ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ) : ( + <FileList + files={files} + loading={loading} + onSelect={handleSelectFile} + onDelete={handleDelete} + /> + )} + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx index 9ac0a94..aaba90c 100644 --- a/makima/frontend/src/routes/listen.tsx +++ b/makima/frontend/src/routes/listen.tsx @@ -112,10 +112,11 @@ export default function ListenPage() { setIsListening(true); }, [isListening, mic, ws]); - const handleReset = useCallback(() => { + const handleNew = useCallback(() => { + // Stop current session - backend auto-saves transcript on disconnect mic.stop(); if (ws.isConnected) { - ws.stopSession("reset"); + ws.stopSession("new_session"); } ws.clearTranscripts(); ws.disconnect(); @@ -147,8 +148,9 @@ export default function ListenPage() { isConnected={ws.isConnected} micStatus={mic.status} micVolume={mic.volume} + hasTranscripts={ws.transcripts.length > 0} onToggle={handleToggle} - onReset={handleReset} + onNew={handleNew} error={error} /> </div> |
