diff options
| author | soryu <soryu@soryu.co> | 2025-12-23 19:11:57 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-23 19:11:57 +0000 |
| commit | f5222a7ae5ade5589436778cb01fc0abe625b3c3 (patch) | |
| tree | 6e9739517d371179e6018412cba011b3f38868ef | |
| parent | 3c0adec8e3a9dd3bc34251e87e0fb5314793426d (diff) | |
| download | soryu-f5222a7ae5ade5589436778cb01fc0abe625b3c3.tar.gz soryu-f5222a7ae5ade5589436778cb01fc0abe625b3c3.zip | |
Add editable file sections and a drag&drop feature
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 289 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 61 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileList.tsx | 14 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 65 | ||||
| -rw-r--r-- | makima/src/server/handlers/listen.rs | 124 |
5 files changed, 466 insertions, 87 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 9d008e2..06b2b75 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -1,11 +1,18 @@ +import { useState, useRef, useEffect } from "react"; import type { BodyElement } from "../../lib/api"; import { ChartRenderer } from "../charts/ChartRenderer"; interface BodyRendererProps { elements: BodyElement[]; + isEditing?: boolean; + onUpdate?: (index: number, element: BodyElement) => void; + onReorder?: (fromIndex: number, toIndex: number) => void; } -export function BodyRenderer({ elements }: BodyRendererProps) { +export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) { + const [draggedIndex, setDraggedIndex] = useState<number | null>(null); + const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); + if (elements.length === 0) { return ( <div className="text-[#555] font-mono text-sm italic"> @@ -14,21 +21,123 @@ export function BodyRenderer({ elements }: BodyRendererProps) { ); } + const handleDragStart = (index: number) => (e: React.DragEvent) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", index.toString()); + }; + + const handleDragOver = (index: number) => (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (draggedIndex !== null && draggedIndex !== index) { + setDragOverIndex(index); + } + }; + + const handleDragLeave = () => { + setDragOverIndex(null); + }; + + const handleDrop = (toIndex: number) => (e: React.DragEvent) => { + e.preventDefault(); + const fromIndex = draggedIndex; + setDraggedIndex(null); + setDragOverIndex(null); + + if (fromIndex !== null && fromIndex !== toIndex && onReorder) { + onReorder(fromIndex, toIndex); + } + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + return ( - <div className="space-y-4"> + <div className="space-y-1"> {elements.map((element, index) => ( - <BodyElementRenderer key={index} element={element} /> + <div + key={index} + className={`group flex items-start gap-2 py-1 transition-all ${ + draggedIndex === index ? "opacity-50" : "" + } ${ + dragOverIndex === index + ? "border-t-2 border-[#75aafc] -mt-[2px] pt-[calc(0.25rem+2px)]" + : "" + }`} + onDragOver={handleDragOver(index)} + onDragLeave={handleDragLeave} + onDrop={handleDrop(index)} + > + {/* Drag handle - only show in edit mode */} + {isEditing && onReorder && ( + <div + draggable + onDragStart={handleDragStart(index)} + onDragEnd={handleDragEnd} + className="flex-shrink-0 w-5 h-6 flex items-center justify-center cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity text-[#555] hover:text-[#75aafc]" + title="Drag to reorder" + > + <svg + width="12" + height="12" + viewBox="0 0 12 12" + fill="currentColor" + > + <circle cx="3" cy="2" r="1.5" /> + <circle cx="9" cy="2" r="1.5" /> + <circle cx="3" cy="6" r="1.5" /> + <circle cx="9" cy="6" r="1.5" /> + <circle cx="3" cy="10" r="1.5" /> + <circle cx="9" cy="10" r="1.5" /> + </svg> + </div> + )} + <div className="flex-1"> + <BodyElementRenderer + element={element} + onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined} + /> + </div> + </div> ))} </div> ); } -function BodyElementRenderer({ element }: { element: BodyElement }) { +function BodyElementRenderer({ + element, + onUpdate, +}: { + element: BodyElement; + onUpdate?: (element: BodyElement) => void; +}) { switch (element.type) { case "heading": - return <HeadingElement level={element.level} text={element.text} />; + return ( + <HeadingElement + level={element.level} + text={element.text} + onUpdate={ + onUpdate + ? (text) => onUpdate({ ...element, text }) + : undefined + } + /> + ); case "paragraph": - return <ParagraphElement text={element.text} />; + return ( + <ParagraphElement + text={element.text} + onUpdate={ + onUpdate + ? (text) => onUpdate({ ...element, text }) + : undefined + } + /> + ); case "chart": return ( <ChartElement @@ -51,29 +160,157 @@ function BodyElementRenderer({ element }: { element: BodyElement }) { } } -function HeadingElement({ level, text }: { level: number; text: string }) { - const className = "font-mono text-[#9bc3ff]"; - - switch (level) { - case 1: - return <h1 className={`${className} text-2xl font-bold`}>{text}</h1>; - case 2: - return <h2 className={`${className} text-xl font-bold`}>{text}</h2>; - case 3: - return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>; - case 4: - return <h4 className={`${className} text-base font-semibold`}>{text}</h4>; - case 5: - return <h5 className={`${className} text-sm font-semibold`}>{text}</h5>; - case 6: - return <h6 className={`${className} text-xs font-semibold`}>{text}</h6>; - default: - return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>; +function HeadingElement({ + level, + text, + onUpdate, +}: { + level: number; + text: string; + onUpdate?: (text: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(text); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + setEditText(text); + }, [text]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleSave = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + setEditText(text); + setIsEditing(false); + } + }; + + const baseClassName = "font-mono text-[#9bc3ff]"; + const sizeClasses: Record<number, string> = { + 1: "text-2xl font-bold", + 2: "text-xl font-bold", + 3: "text-lg font-semibold", + 4: "text-base font-semibold", + 5: "text-sm font-semibold", + 6: "text-xs font-semibold", + }; + const sizeClass = sizeClasses[level] || sizeClasses[3]; + + if (isEditing && onUpdate) { + return ( + <input + ref={inputRef} + type="text" + value={editText} + onChange={(e) => setEditText(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`} + /> + ); } + + const HeadingTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + return ( + <HeadingTag + className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`} + onClick={() => onUpdate && setIsEditing(true)} + > + {text} + </HeadingTag> + ); } -function ParagraphElement({ text }: { text: string }) { - return <p className="font-mono text-sm text-white/80 leading-relaxed">{text}</p>; +function ParagraphElement({ + text, + onUpdate, +}: { + text: string; + onUpdate?: (text: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(text); + const textareaRef = useRef<HTMLTextAreaElement>(null); + + useEffect(() => { + setEditText(text); + }, [text]); + + useEffect(() => { + if (isEditing && textareaRef.current) { + textareaRef.current.focus(); + // Auto-resize textarea + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"; + } + }, [isEditing]); + + const handleSave = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setEditText(text); + setIsEditing(false); + } + // Ctrl/Cmd + Enter to save + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + handleSave(); + } + }; + + const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setEditText(e.target.value); + // Auto-resize + e.target.style.height = "auto"; + e.target.style.height = e.target.scrollHeight + "px"; + }; + + if (isEditing && onUpdate) { + return ( + <div className="relative"> + <textarea + ref={textareaRef} + value={editText} + onChange={handleInput} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]" + /> + <div className="text-[10px] text-[#555] font-mono mt-1"> + Ctrl+Enter to save, Esc to cancel + </div> + </div> + ); + } + + return ( + <p + className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`} + onClick={() => onUpdate && setIsEditing(true)} + > + {text} + </p> + ); } function ChartElement({ diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index ffc67dd..2bf4c03 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -8,6 +8,8 @@ interface FileDetailProps { onBack: () => void; onSave: (id: string, name: string, description: string) => void; onDelete: (id: string) => void; + onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void; + onBodyReorder?: (fromIndex: number, toIndex: number) => void; } export function FileDetail({ @@ -16,6 +18,8 @@ export function FileDetail({ onBack, onSave, onDelete, + onBodyElementUpdate, + onBodyReorder, }: FileDetailProps) { const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); @@ -143,33 +147,34 @@ export function FileDetail({ <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-3"> Content </h3> - <BodyRenderer elements={file.body} /> + <BodyRenderer + elements={file.body} + isEditing={isEditing} + onUpdate={onBodyElementUpdate} + onReorder={onBodyReorder} + /> </div> - {/* Collapsible Transcript Section */} - <div className="border-t border-dashed border-[rgba(117,170,252,0.35)] pt-4"> - <button - onClick={() => setTranscriptExpanded(!transcriptExpanded)} - className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors uppercase w-full text-left" - > - <span - className={`transition-transform ${ - transcriptExpanded ? "rotate-90" : "" - }`} + {/* Collapsible Transcript Section - only show if there are entries */} + {file.transcript.length > 0 && ( + <div className="border-t border-dashed border-[rgba(117,170,252,0.35)] pt-4"> + <button + onClick={() => setTranscriptExpanded(!transcriptExpanded)} + className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors uppercase w-full text-left" > - > - </span> - Transcript ({file.transcript.length} entries) - </button> + <span + className={`transition-transform ${ + transcriptExpanded ? "rotate-90" : "" + }`} + > + > + </span> + Transcript ({file.transcript.length} entries) + </button> - {transcriptExpanded && ( - <div className="mt-4 space-y-3 pl-4"> - {file.transcript.length === 0 ? ( - <div className="text-[#9bc3ff] text-sm font-mono opacity-60"> - No transcript entries. - </div> - ) : ( - file.transcript.map((entry) => ( + {transcriptExpanded && ( + <div className="mt-4 space-y-3 pl-4"> + {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"> @@ -183,11 +188,11 @@ export function FileDetail({ {entry.text} </p> </div> - )) - )} - </div> - )} - </div> + ))} + </div> + )} + </div> + )} </div> </div> ); diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx index 7e1eea4..a859aa1 100644 --- a/makima/frontend/src/components/files/FileList.tsx +++ b/makima/frontend/src/components/files/FileList.tsx @@ -5,6 +5,7 @@ interface FileListProps { loading: boolean; onSelect: (id: string) => void; onDelete: (id: string) => void; + onCreate: () => void; } function formatDuration(seconds: number | null): string { @@ -30,6 +31,7 @@ export function FileList({ loading, onSelect, onDelete, + onCreate, }: FileListProps) { if (loading) { return ( @@ -41,8 +43,16 @@ export function FileList({ 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 className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + FILES// + </div> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" + > + + New + </button> </div> <div className="flex-1 overflow-y-auto"> diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 00c334d..79544c5 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -10,9 +10,10 @@ import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; export default function FilesPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { files, loading, error, fetchFile, editFile, removeFile } = useFiles(); + const { files, loading, error, fetchFile, editFile, removeFile, saveFile } = useFiles(); const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null); const [detailLoading, setDetailLoading] = useState(false); + const [creating, setCreating] = useState(false); // Load file detail when URL has an id useEffect(() => { @@ -72,6 +73,63 @@ export default function FilesPage() { [fileDetail] ); + const handleBodyElementUpdate = useCallback( + async (index: number, element: BodyElement) => { + if (fileDetail && id) { + // Create new body array with updated element + const newBody = [...fileDetail.body]; + newBody[index] = element; + + // Update local state immediately for responsiveness + setFileDetail({ + ...fileDetail, + body: newBody, + }); + + // Save to backend + await editFile(id, { body: newBody }); + } + }, + [fileDetail, id, editFile] + ); + + const handleBodyReorder = useCallback( + async (fromIndex: number, toIndex: number) => { + if (fileDetail && id) { + // Create new body array with reordered elements + const newBody = [...fileDetail.body]; + const [movedElement] = newBody.splice(fromIndex, 1); + newBody.splice(toIndex, 0, movedElement); + + // Update local state immediately for responsiveness + setFileDetail({ + ...fileDetail, + body: newBody, + }); + + // Save to backend + await editFile(id, { body: newBody }); + } + }, + [fileDetail, id, editFile] + ); + + const handleCreate = useCallback(async () => { + if (creating) return; + setCreating(true); + try { + const newFile = await saveFile({ + name: `Untitled ${new Date().toLocaleDateString()}`, + transcript: [], + }); + if (newFile) { + navigate(`/files/${newFile.id}`); + } + } finally { + setCreating(false); + } + }, [creating, saveFile, navigate]); + return ( <div className="relative z-10 h-screen flex flex-col overflow-hidden"> <Masthead showTicker={false} showNav /> @@ -92,6 +150,8 @@ export default function FilesPage() { onBack={handleBack} onSave={handleSave} onDelete={handleDelete} + onBodyElementUpdate={handleBodyElementUpdate} + onBodyReorder={handleBodyReorder} /> </div> <div className="shrink-0"> @@ -105,9 +165,10 @@ export default function FilesPage() { ) : ( <FileList files={files} - loading={loading} + loading={loading || creating} onSelect={handleSelectFile} onDelete={handleDelete} + onCreate={handleCreate} /> )} </main> diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs index 3055cb7..5fc5cea 100644 --- a/makima/src/server/handlers/listen.rs +++ b/makima/src/server/handlers/listen.rs @@ -512,12 +512,12 @@ fn decode_audio_chunk(data: &[u8], format: &StartMessage) -> Vec<f32> { } } -/// Deduplicate transcript entries by removing entries with similar start times and text. +/// Deduplicate transcript entries by removing entries with similar times and text. /// -/// Entries are considered duplicates if: -/// - Start times are within 0.5 seconds of each other -/// - Speaker is the same -/// - Text is identical or one is a substring of the other +/// Entries are considered duplicates if any of these are true: +/// - Start times are within 1.5 seconds AND text is similar (same, substring, or high overlap) +/// - Time ranges overlap significantly AND text is similar +/// - Text is identical regardless of timing fn deduplicate_transcripts(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> { if entries.is_empty() { return vec![]; @@ -530,49 +530,115 @@ fn deduplicate_transcripts(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> let mut result: Vec<TranscriptEntry> = Vec::new(); for entry in sorted { + // Normalize text for comparison + let entry_text_normalized = normalize_text(&entry.text); + // Check if this entry is a duplicate of any existing entry - let is_duplicate = result.iter().any(|existing| { - // Check if start times are close (within 0.5 seconds) - let time_close = (existing.start - entry.start).abs() < 0.5; + let duplicate_idx = result.iter().position(|existing| { + let existing_text_normalized = normalize_text(&existing.text); // Check if same speaker let same_speaker = existing.speaker == entry.speaker; - // Check if text matches or one contains the other - let text_match = existing.text == entry.text - || existing.text.contains(&entry.text) - || entry.text.contains(&existing.text); - - time_close && same_speaker && text_match + // Check if start times are identical or very close + let start_identical = (existing.start - entry.start).abs() < 0.1; + let start_close = (existing.start - entry.start).abs() < 1.5; + + // Check if time ranges overlap + let time_overlap = existing.start < entry.end && entry.start < existing.end; + + // Check various text similarity conditions + let text_identical = existing_text_normalized == entry_text_normalized; + let text_contained = existing_text_normalized.contains(&entry_text_normalized) + || entry_text_normalized.contains(&existing_text_normalized); + let text_similar = text_similarity(&existing_text_normalized, &entry_text_normalized) > 0.7; + + // Duplicate conditions: + // 1. Same speaker + identical start time (different end times = same segment refined) + // 2. Same speaker + close start + similar text + // 3. Same speaker + overlapping time + similar text + // 4. Identical text (likely a re-transcription) + (same_speaker && start_identical) + || (same_speaker && start_close && (text_identical || text_contained || text_similar)) + || (same_speaker && time_overlap && (text_identical || text_contained)) + || text_identical }); - if !is_duplicate { - result.push(entry); - } else { - // If duplicate, check if the new entry has longer text and update - for existing in &mut result { - let time_close = (existing.start - entry.start).abs() < 0.5; - let same_speaker = existing.speaker == entry.speaker; - - if time_close && same_speaker && entry.text.len() > existing.text.len() { - // Keep the longer text version - existing.text = entry.text.clone(); - existing.end = entry.end; - break; + match duplicate_idx { + Some(idx) => { + // If the new entry has longer text, update the existing one + if entry.text.len() > result[idx].text.len() { + result[idx].text = entry.text.clone(); + result[idx].end = result[idx].end.max(entry.end); + } else { + // Extend end time if needed + result[idx].end = result[idx].end.max(entry.end); + } + } + None => { + result.push(entry); + } + } + } + + // Second pass: merge adjacent segments with same speaker and similar text + let mut merged: Vec<TranscriptEntry> = Vec::new(); + for entry in result { + if let Some(last) = merged.last_mut() { + // Check if this should be merged with the previous entry + let same_speaker = last.speaker == entry.speaker; + let adjacent = (entry.start - last.end).abs() < 0.5; + let text_overlap = normalize_text(&last.text).contains(&normalize_text(&entry.text)) + || normalize_text(&entry.text).contains(&normalize_text(&last.text)); + + if same_speaker && adjacent && text_overlap { + // Merge: keep longer text, extend time range + if entry.text.len() > last.text.len() { + last.text = entry.text; } + last.end = last.end.max(entry.end); + continue; } } + merged.push(entry); } // Reassign IDs to be sequential - for (i, entry) in result.iter_mut().enumerate() { + for (i, entry) in merged.iter_mut().enumerate() { let parts: Vec<&str> = entry.id.split('-').collect(); if let Some(session_prefix) = parts.first() { entry.id = format!("{}-{}", session_prefix, i + 1); } } - result + merged +} + +/// Normalize text for comparison by lowercasing and collapsing whitespace. +fn normalize_text(text: &str) -> String { + text.to_lowercase() + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} + +/// Calculate text similarity as a ratio of shared words. +fn text_similarity(a: &str, b: &str) -> f32 { + if a.is_empty() || b.is_empty() { + return 0.0; + } + + let words_a: std::collections::HashSet<&str> = a.split_whitespace().collect(); + let words_b: std::collections::HashSet<&str> = b.split_whitespace().collect(); + + let intersection = words_a.intersection(&words_b).count(); + let union = words_a.union(&words_b).count(); + + if union == 0 { + 0.0 + } else { + intersection as f32 / union as f32 + } } /// Process audio using sliding window through STT and streaming diarization models. |
