Affordances on MicroSteps rows (thumbs/edit/speaker/just-start) and transcript-viewer segments (star, copy, delete) used opacity-0 → opacity-100 on group-hover, which (a) hid them from keyboard users until focus and (b) violated the "always-visible affordance" line in the Kon design audit. Replace with opacity-30 baseline + group-hover:opacity-100 + focus-visible:opacity-100 (MicroSteps) / focus-within:opacity-100 (viewer, since the actions sit inside an interactive segment row). Result: affordances are calm but visible at rest, lift on hover or keyboard focus, never invisible. HistoryPage was checked — its row checkbox at line 757 is already opacity-50 → opacity-100 on hover, the pattern this PR is moving toward, so no change needed there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
607 lines
22 KiB
Svelte
607 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, tick } from "svelte";
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
|
import type { TranscriptDto, TranscriptEntry, ViewerSegment } from "$lib/types/app";
|
|
import SpeakerButton from "$lib/components/SpeakerButton.svelte";
|
|
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
|
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
|
import { errorMessage } from "$lib/utils/errors.js";
|
|
import { parseStoredJson } from "$lib/utils/storage.js";
|
|
import { mapTranscriptRow, saveTranscriptMeta } from "$lib/stores/page.svelte.js";
|
|
import { toasts } from "$lib/stores/toasts.svelte.ts";
|
|
import { DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
|
|
|
|
let item = $state<TranscriptEntry | null>(null);
|
|
let audioEl = $state<HTMLAudioElement | null>(null);
|
|
let playing = $state(false);
|
|
let currentTime = $state(0);
|
|
let duration = $state(0);
|
|
let playbackRate = $state(1);
|
|
let searchQuery = $state("");
|
|
let activeSegmentIdx = $state(-1);
|
|
let editingIdx = $state(-1);
|
|
let editingText = $state("");
|
|
let showTimestamps = $state(true);
|
|
let showStarredOnly = $state(false);
|
|
let animFrameId: number | null = null;
|
|
let segmentRefs = $state<Array<HTMLElement | null>>([]);
|
|
let viewerMode = $state<"view" | "edit">("view");
|
|
let textDraft = $state("");
|
|
let textDirty = $state(false);
|
|
let textSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let editingTextareaEl = $state<HTMLTextAreaElement | null>(null);
|
|
let textLearnBase = $state("");
|
|
|
|
function stopAudio() {
|
|
if (audioEl) audioEl.pause();
|
|
playing = false;
|
|
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
}
|
|
|
|
function buildAudio(audioPath: string | null) {
|
|
stopAudio();
|
|
audioEl = null;
|
|
duration = 0;
|
|
currentTime = 0;
|
|
if (!audioPath) return;
|
|
|
|
const src = convertFileSrc(audioPath);
|
|
const audio = new Audio(src);
|
|
audio.playbackRate = playbackRate;
|
|
audio.onloadedmetadata = () => { duration = audio.duration; };
|
|
audio.onended = () => {
|
|
playing = false;
|
|
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
};
|
|
audio.onerror = () => { playing = false; };
|
|
audioEl = audio;
|
|
}
|
|
|
|
function loadViewerItem(nextItem: TranscriptEntry | null) {
|
|
item = nextItem;
|
|
textDraft = nextItem?.text ?? "";
|
|
textLearnBase = nextItem?.text ?? "";
|
|
activeSegmentIdx = -1;
|
|
editingIdx = -1;
|
|
editingText = "";
|
|
segmentRefs.length = 0;
|
|
buildAudio(nextItem?.audioPath ?? null);
|
|
}
|
|
|
|
// Hydrate the viewer from a transcript ID handed off via localStorage.
|
|
// The full row is fetched from SQLite via `get_transcript` so transcript
|
|
// text never lives in localStorage where any same-origin script could
|
|
// read it.
|
|
async function loadFromHandoff(raw: string | null) {
|
|
const handoff = parseStoredJson<{ id?: unknown }>(raw);
|
|
const id = handoff?.id != null ? String(handoff.id) : "";
|
|
if (!id) {
|
|
loadViewerItem(null);
|
|
return;
|
|
}
|
|
try {
|
|
const dto = await invoke<TranscriptDto | null>("get_transcript", { id });
|
|
loadViewerItem(dto ? mapTranscriptRow(dto) : null);
|
|
} catch (err) {
|
|
console.warn("viewer get_transcript failed", errorMessage(err));
|
|
loadViewerItem(null);
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
loadFromHandoff(localStorage.getItem("kon_viewer_item"));
|
|
const mode = localStorage.getItem("kon_viewer_mode");
|
|
if (mode === "edit" || mode === "view") viewerMode = mode;
|
|
|
|
// Listen for new items via storage events
|
|
window.addEventListener("storage", handleStorageChange);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
stopAudio();
|
|
window.removeEventListener("storage", handleStorageChange);
|
|
// Flush any pending text edit before the window tears down.
|
|
if (textSaveTimer) {
|
|
clearTimeout(textSaveTimer);
|
|
textSaveTimer = null;
|
|
}
|
|
if (viewerMode === "edit") {
|
|
commitTextEdit(true);
|
|
} else if (textDirty) {
|
|
commitTextEdit();
|
|
}
|
|
});
|
|
|
|
async function maybeLearnCorrections(originalText: string, editedText: string) {
|
|
if (!item || !originalText.trim() || !editedText.trim() || originalText === editedText) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const learned = await invoke<Array<{ term: string }>>("learn_profile_terms_from_edit_cmd", {
|
|
profileId: item.profileId || DEFAULT_PROFILE_ID,
|
|
originalText,
|
|
editedText,
|
|
});
|
|
if (learned.length > 0) {
|
|
const count = learned.length;
|
|
toasts.success(
|
|
count === 1 ? "Learned 1 profile term" : `Learned ${count} profile terms`,
|
|
learned.map((entry) => entry.term).join(", "),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn("viewer maybeLearnCorrections failed", errorMessage(err));
|
|
}
|
|
}
|
|
|
|
function handleStorageChange(e: StorageEvent) {
|
|
if (e.key === "kon_viewer_item" && e.newValue) {
|
|
void loadFromHandoff(e.newValue);
|
|
}
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (!audioEl) return;
|
|
if (playing) {
|
|
audioEl.pause();
|
|
playing = false;
|
|
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
|
} else {
|
|
audioEl.play();
|
|
playing = true;
|
|
tickTime();
|
|
}
|
|
}
|
|
|
|
function tickTime() {
|
|
if (audioEl) {
|
|
currentTime = audioEl.currentTime;
|
|
updateActiveSegment();
|
|
if (!audioEl.paused) {
|
|
animFrameId = requestAnimationFrame(tickTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateActiveSegment() {
|
|
if (!item?.segments) return;
|
|
const t = currentTime;
|
|
for (let i = 0; i < item.segments.length; i++) {
|
|
const seg = item.segments[i];
|
|
if (t >= seg.start && t < seg.end) {
|
|
if (activeSegmentIdx !== i) {
|
|
activeSegmentIdx = i;
|
|
// Auto-scroll to active segment
|
|
segmentRefs[i]?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function seekTo(e: Event) {
|
|
const val = parseFloat((e.currentTarget as HTMLInputElement).value);
|
|
if (audioEl) {
|
|
audioEl.currentTime = val;
|
|
currentTime = val;
|
|
updateActiveSegment();
|
|
}
|
|
}
|
|
|
|
function seekToTime(time: number) {
|
|
if (audioEl) {
|
|
audioEl.currentTime = time;
|
|
currentTime = time;
|
|
if (!playing) {
|
|
audioEl.play();
|
|
playing = true;
|
|
tickTime();
|
|
}
|
|
updateActiveSegment();
|
|
}
|
|
}
|
|
|
|
function setSpeed(speed: number) {
|
|
playbackRate = speed;
|
|
if (audioEl) audioEl.playbackRate = speed;
|
|
}
|
|
|
|
|
|
// Search: find matching segments
|
|
let matchingSegments = $derived.by(() => {
|
|
if (!searchQuery.trim() || !item?.segments) return new Set<number>();
|
|
const q = searchQuery.toLowerCase();
|
|
const matches = new Set<number>();
|
|
item.segments.forEach((seg, i) => {
|
|
if (seg.text.toLowerCase().includes(q)) matches.add(i);
|
|
});
|
|
return matches;
|
|
});
|
|
|
|
let matchCount = $derived.by(() => matchingSegments.size);
|
|
|
|
// Highlight search matches in text (XSS-safe: escapes HTML entities first)
|
|
function highlightText(text: string) {
|
|
if (!searchQuery.trim()) return text;
|
|
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
const q = searchQuery.trim();
|
|
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
|
return escaped.replace(regex, '<mark class="bg-accent/25 text-text rounded px-0.5">$1</mark>');
|
|
}
|
|
|
|
// Segment editing
|
|
async function startEditing(idx: number) {
|
|
if (!item?.segments?.[idx]) return;
|
|
editingIdx = idx;
|
|
editingText = item.segments[idx].text;
|
|
await tick();
|
|
editingTextareaEl?.focus();
|
|
editingTextareaEl?.select();
|
|
}
|
|
|
|
function finishEditing() {
|
|
if (editingIdx >= 0 && item?.segments) {
|
|
const previousText = item.text;
|
|
item.segments[editingIdx].text = editingText.trim();
|
|
// Update full text
|
|
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
|
textDraft = item.text;
|
|
saveItemToHistory();
|
|
// Task 2.5 — persist segment boundaries (text + starred flags) to
|
|
// SQLite via segments_json. update_transcript above only covers text
|
|
// and title; the segment array needs the dedicated meta command.
|
|
persistSegments();
|
|
void maybeLearnCorrections(previousText, item.text);
|
|
textLearnBase = item.text;
|
|
}
|
|
editingIdx = -1;
|
|
editingText = "";
|
|
}
|
|
|
|
function deleteSegment(idx: number) {
|
|
if (item?.segments) {
|
|
item.segments.splice(idx, 1);
|
|
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
|
saveItemToHistory();
|
|
// Task 2.5 — persist the shrunk segment array.
|
|
persistSegments();
|
|
if (editingIdx === idx) { editingIdx = -1; }
|
|
}
|
|
}
|
|
|
|
function toggleStar(idx: number) {
|
|
if (item?.segments) {
|
|
item.segments[idx].starred = !item.segments[idx].starred;
|
|
// Task 2.5 — persist the updated segments array (incl. starred flags)
|
|
// via the new update_transcript_meta_cmd. Before this, starring a
|
|
// segment was in-memory only and vanished on reload.
|
|
persistSegments();
|
|
}
|
|
}
|
|
|
|
// Task 2.5 — single entry point for segments_json persistence. Callers
|
|
// already guard with `if (item?.segments)` so the snapshot here is safe;
|
|
// the @ts-ignore matches the file-wide pattern where `item` inferred as
|
|
// `null` from `$state(null)` narrows to `never` after guards. Replacing
|
|
// the annotation needs a full viewer re-typing pass — out of scope for
|
|
// Task 2.5, tracked by the file's 80-odd pre-existing TS errors.
|
|
function persistSegments() {
|
|
if (!item) return;
|
|
saveTranscriptMeta(item.id, { segments: item.segments });
|
|
}
|
|
|
|
function copySegment(idx: number) {
|
|
if (item?.segments?.[idx]) {
|
|
const text = item.segments[idx].text.trim();
|
|
navigator.clipboard.writeText(text).catch(() => {
|
|
invoke("copy_to_clipboard", { text }).catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Persist edits back to SQLite. `update_transcript` covers `text` / `title`;
|
|
// segment structure, per-segment starred flags, manualTags, template and
|
|
// language go through `update_transcript_meta_cmd` (Task 2.5) via the
|
|
// store's `saveTranscriptMeta` helper — callers in this file wire those
|
|
// up alongside the plain-text update below.
|
|
//
|
|
// We re-stamp the localStorage handoff with the same ID so a sibling
|
|
// viewer window receives a `storage` event and re-fetches from SQLite.
|
|
// The handoff intentionally contains no transcript content — see
|
|
// `loadFromHandoff` for the rationale.
|
|
function saveItemToHistory() {
|
|
if (!item) return;
|
|
try {
|
|
localStorage.setItem(
|
|
"kon_viewer_item",
|
|
JSON.stringify({ id: String(item.id), stamp: Date.now() }),
|
|
);
|
|
} catch {}
|
|
// Best-effort SQLite persistence. Silently ignored in browser preview
|
|
// (no Tauri runtime) — matches the main-window store's behaviour.
|
|
invoke("update_transcript", {
|
|
id: String(item.id),
|
|
text: item.text ?? null,
|
|
title: item.title ?? null,
|
|
}).catch((err) => {
|
|
console.warn("viewer saveItemToHistory: update_transcript failed", errorMessage(err));
|
|
});
|
|
}
|
|
|
|
function scheduleTextSave() {
|
|
textDirty = true;
|
|
if (textSaveTimer) clearTimeout(textSaveTimer);
|
|
textSaveTimer = setTimeout(() => {
|
|
commitTextEdit();
|
|
}, 400);
|
|
}
|
|
|
|
function commitTextEdit(learnCorrections = false) {
|
|
if (!item) return;
|
|
const next = textDraft;
|
|
if (next !== item.text) {
|
|
item.text = next;
|
|
// The compact preview in History is derived from `preview`; keep it
|
|
// roughly in sync so the list does not show stale copy after an edit.
|
|
item.preview = next.slice(0, 120);
|
|
saveItemToHistory();
|
|
}
|
|
if (learnCorrections && next !== textLearnBase) {
|
|
void maybeLearnCorrections(textLearnBase, next);
|
|
textLearnBase = next;
|
|
}
|
|
if (next === item.text) {
|
|
textDirty = false;
|
|
return;
|
|
}
|
|
textDirty = false;
|
|
}
|
|
|
|
// Filtered segments (starred filter + search)
|
|
let visibleSegments = $derived.by(() => {
|
|
if (!item?.segments) return [] as ViewerSegment[];
|
|
let segs: ViewerSegment[] = item.segments.map((seg, i) => ({ ...seg, _idx: i }));
|
|
if (showStarredOnly) {
|
|
segs = segs.filter((s) => s.starred);
|
|
}
|
|
return segs;
|
|
});
|
|
|
|
// Window drag — pointerdown + setPointerCapture avoids the mousedown
|
|
// latency that makes KWin's initial grab feel draggy.
|
|
function handleDragStart(e: PointerEvent) {
|
|
if (e.button !== 0) return;
|
|
if ((e.target as HTMLElement | null)?.closest("button")) return;
|
|
if ((e.target as HTMLElement | null)?.closest("input")) return;
|
|
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
|
|
getCurrentWindow().startDragging();
|
|
}
|
|
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full bg-bg">
|
|
<!-- Drag handle -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
|
onpointerdown={handleDragStart}
|
|
>
|
|
<span class="text-[12px] font-medium text-text-secondary tracking-wide">
|
|
Kon - {viewerMode === "edit" ? "Editor" : "Viewer"}
|
|
</span>
|
|
<div class="flex-1"></div>
|
|
</div>
|
|
|
|
{#if item}
|
|
<!-- Item info -->
|
|
<div class="px-5 pt-3 pb-2 flex items-start gap-2">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-[13px] text-text font-medium">
|
|
{item.title || "Transcript"}
|
|
</p>
|
|
<p class="text-[10px] text-text-tertiary mt-0.5">
|
|
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
|
|
</p>
|
|
</div>
|
|
{#if item.text?.trim()}
|
|
<SpeakerButton text={item.text} label="Read transcript aloud" size={14} />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Media controls -->
|
|
{#if audioEl}
|
|
<div class="flex items-center gap-3 px-5 py-2 border-b border-border-subtle">
|
|
<!-- Play/Pause -->
|
|
<button
|
|
class="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0
|
|
{playing ? 'bg-accent text-white' : 'bg-accent/15 text-accent hover:bg-accent/25'}"
|
|
onclick={togglePlay}
|
|
aria-label={playing ? "Pause" : "Play"}
|
|
>
|
|
{#if playing}
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
</svg>
|
|
{:else}
|
|
<svg class="w-4 h-4 ml-0.5" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Time -->
|
|
<span class="text-[11px] text-text-tertiary tabular-nums min-w-[70px]">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
|
|
<!-- Seek bar -->
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={duration || 0}
|
|
step="0.1"
|
|
value={currentTime}
|
|
oninput={seekTo}
|
|
class="flex-1 accent-accent h-1"
|
|
data-no-transition
|
|
/>
|
|
|
|
<!-- Speed pills -->
|
|
<div class="flex gap-0.5 flex-shrink-0">
|
|
{#each PLAYBACK_SPEEDS as speed}
|
|
<button
|
|
class="text-[9px] px-1.5 py-0.5 rounded-full
|
|
{playbackRate === speed
|
|
? 'bg-accent/15 text-accent font-medium'
|
|
: 'text-text-tertiary hover:text-text-secondary'}"
|
|
onclick={() => setSpeed(speed)}
|
|
>{speed}x</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Search bar + view controls (segment-specific; hidden in edit mode) -->
|
|
{#if viewerMode !== "edit"}
|
|
<div class="flex items-center gap-2 px-5 py-2 border-b border-border-subtle">
|
|
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent flex-1">
|
|
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="M21 21l-4.35-4.35" stroke-linecap="round" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary focus:outline-none"
|
|
placeholder="Search transcript..."
|
|
bind:value={searchQuery}
|
|
data-no-transition
|
|
/>
|
|
{#if searchQuery}
|
|
<span class="text-[10px] text-text-tertiary">{matchCount} matches</span>
|
|
<button class="text-[10px] text-text-tertiary hover:text-text" onclick={() => searchQuery = ""}>Clear</button>
|
|
{/if}
|
|
</div>
|
|
<!-- Compact mode toggle -->
|
|
<button
|
|
class="text-[10px] px-2 py-1 rounded-lg flex-shrink-0
|
|
{showTimestamps ? 'text-text-tertiary hover:text-text-secondary' : 'text-accent bg-accent/10'}"
|
|
onclick={() => showTimestamps = !showTimestamps}
|
|
title={showTimestamps ? "Hide timestamps (compact)" : "Show timestamps"}
|
|
>Compact</button>
|
|
<!-- Starred filter -->
|
|
<button
|
|
class="text-[10px] px-2 py-1 rounded-lg flex-shrink-0
|
|
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary'}"
|
|
onclick={() => showStarredOnly = !showStarredOnly}
|
|
title={showStarredOnly ? "Show all segments" : "Show starred only"}
|
|
>Starred</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Transcript segments / editor -->
|
|
<div class="flex-1 overflow-y-auto px-5 py-3 min-h-0">
|
|
{#if viewerMode === "edit"}
|
|
<textarea
|
|
class="w-full h-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px]
|
|
text-text leading-relaxed resize-none focus:outline-none focus:border-accent"
|
|
bind:value={textDraft}
|
|
oninput={scheduleTextSave}
|
|
onblur={() => commitTextEdit(true)}
|
|
data-no-transition
|
|
placeholder="Edit the transcript..."
|
|
></textarea>
|
|
{:else if item.segments && item.segments.length > 0}
|
|
<div class="flex flex-col gap-1">
|
|
{#each visibleSegments as seg (seg._idx)}
|
|
<div
|
|
bind:this={segmentRefs[seg._idx]}
|
|
class="group flex gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors
|
|
{activeSegmentIdx === seg._idx
|
|
? 'bg-accent/10 border-l-2 border-accent'
|
|
: matchingSegments.has(seg._idx)
|
|
? 'bg-warning/5 border-l-2 border-warning/40'
|
|
: 'border-l-2 border-transparent hover:bg-hover'}"
|
|
onclick={() => seekToTime(seg.start)}
|
|
ondblclick={() => startEditing(seg._idx)}
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown={(e) => { if (e.key === "Enter" && editingIdx !== seg._idx) seekToTime(seg.start); }}
|
|
>
|
|
<!-- Star -->
|
|
<button
|
|
class="mt-0.5 flex-shrink-0 {seg.starred ? 'text-accent' : 'text-text-tertiary opacity-30 group-hover:opacity-100 focus-within:opacity-100'}"
|
|
onclick={(e) => { e.stopPropagation(); toggleStar(seg._idx); }}
|
|
aria-label={seg.starred ? "Unstar" : "Star"}
|
|
>
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill={seg.starred ? "currentColor" : "none"} stroke="currentColor" stroke-width="2">
|
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
</svg>
|
|
</button>
|
|
<!-- Timestamp -->
|
|
{#if showTimestamps}
|
|
<span class="text-[10px] text-text-tertiary tabular-nums flex-shrink-0 mt-0.5 min-w-[36px]">
|
|
{formatTime(seg.start)}
|
|
</span>
|
|
{/if}
|
|
<!-- Text (editable or display) -->
|
|
{#if editingIdx === seg._idx}
|
|
<textarea
|
|
bind:this={editingTextareaEl}
|
|
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
|
|
leading-relaxed resize-none focus:outline-none min-h-[40px]"
|
|
bind:value={editingText}
|
|
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finishEditing(); } if (e.key === "Escape") { editingIdx = -1; } }}
|
|
onblur={finishEditing}
|
|
onclick={(e) => e.stopPropagation()}
|
|
data-no-transition
|
|
></textarea>
|
|
{:else}
|
|
<p class="text-[13px] text-text leading-relaxed flex-1">
|
|
{#if searchQuery.trim()}
|
|
{@html highlightText(seg.text.trim())}
|
|
{:else}
|
|
{seg.text.trim()}
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
<!-- Actions (hover reveal) -->
|
|
<div class="flex items-center gap-1 opacity-30 group-hover:opacity-100 focus-within:opacity-100 flex-shrink-0">
|
|
<button
|
|
class="text-text-tertiary hover:text-accent"
|
|
onclick={(e) => { e.stopPropagation(); copySegment(seg._idx); }}
|
|
title="Copy segment"
|
|
>
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="text-text-tertiary hover:text-danger"
|
|
onclick={(e) => { e.stopPropagation(); deleteSegment(seg._idx); }}
|
|
title="Delete segment"
|
|
>
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<!-- No segments — show full text -->
|
|
<p class="text-[13px] text-text leading-relaxed whitespace-pre-wrap">{item.text}</p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<p class="text-[13px] text-text-tertiary">No transcript loaded</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|