summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx143
-rw-r--r--makima/frontend/src/components/files/FileList.tsx96
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx11
-rw-r--r--makima/frontend/src/hooks/useFiles.ts105
-rw-r--r--makima/frontend/src/lib/api.ts102
-rw-r--r--makima/frontend/src/main.tsx3
-rw-r--r--makima/frontend/src/routes/files.tsx95
-rw-r--r--makima/frontend/src/routes/listen.tsx8
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"
+ >
+ &larr; 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>