Files
Lumotia/src/lib/pages/HistoryPage.svelte
Jake 021a5fc196 v0.2 Phase 7.5: HistoryPage — wrapper sweep
Targeted migration on a 1 225-LOC page. The FTS5 search input stays a
plain <input> (LumotiaCombobox needs an options list; free-text search
doesn't fit the API cleanly enough to justify a rewrite for v0.2).
Row patterns + clear-all modal stay verbatim — their bespoke ARIA and
inline arm-confirm state are core to the page's identity.

  - Card import → LumotiaCard (4 use sites bulk-swapped)
  - EmptyState import → LumotiaEmptyState (4 use sites)
  - LumotiaButton import added for selective use in follow-up sweeps

Bespoke surfaces left verbatim: VirtualSegmentList, audio player,
clear-all type-the-word modal, tag-chip filter bar.

Per-page gate: npm run check (0/0/5704 files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:58:42 +01:00

1227 lines
47 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// @ts-nocheck
import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import {
history,
saveTranscriptMeta,
deleteFromHistory,
deleteFromHistoryById,
renameHistoryEntry,
} from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core";
import {
deriveAutoTags, buildFrontmatter, buildMarkdown, normaliseTag,
} from "$lib/utils/frontmatter.js";
import {
saveTranscriptAsMarkdown,
exportTranscriptsToDir,
} from "$lib/utils/saveMarkdown";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import LumotiaCard from "$lib/ui/LumotiaCard.svelte";
import LumotiaEmptyState from "$lib/ui/LumotiaEmptyState.svelte";
import LumotiaButton from "$lib/ui/LumotiaButton.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
const prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54;
const COLLAPSED_ROW_VERTICAL_PADDING = 24;
const EXPANDED_BASE_HEIGHT = 92;
const EXPANDED_TITLE_INPUT_HEIGHT = 48;
const EXPANDED_TAGS_ROW_HEIGHT = 40;
const AUDIO_PLAYER_HEIGHT = 54;
const HISTORY_PREVIEW_LINES = 2;
const HISTORY_DURATION_WIDTH = 48;
const HISTORY_DATE_WIDTH = 90;
const HISTORY_ICON_WIDTH = 24;
const HISTORY_SOURCE_WIDTH = 14;
const HISTORY_CHEVRON_WIDTH = 14;
const HISTORY_ROW_GAP = 12;
const HISTORY_ROW_HORIZONTAL_PADDING = 32;
const HISTORY_PREVIEW_MIN_WIDTH = 120;
const BUFFER_COUNT = 6;
let searchQuery = $state("");
let showStarredOnly = $state(false);
let activeTagFilter = $state(null); // null = all; string = tag value
let expandedId = $state(null);
// View toggle: "live" shows current transcripts (default), "trash"
// shows soft-deleted transcripts. Backed by `list_trashed_transcripts`
// and `restore_transcript` Tauri commands. Permanent-delete-from-trash
// is intentionally deferred — the 30-day startup purge
// (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs) handles
// hard-removal until the UI for it lands.
let viewMode: "live" | "trash" = $state("live");
let trashItems = $state([]);
let trashLoading = $state(false);
let trashError = $state("");
let restoringId = $state(null);
async function loadTrash() {
trashLoading = true;
trashError = "";
try {
trashItems = await invoke("list_trashed_transcripts", { limit: 500, offset: 0 });
} catch (err) {
trashError = typeof err === "string" ? err : String(err);
toasts.error("Failed to load Trash", trashError);
} finally {
trashLoading = false;
}
}
async function restoreFromTrash(id) {
if (restoringId) return;
restoringId = id;
try {
await invoke("restore_transcript", { id: String(id) });
// Drop the row from the trash list. The live list will pick the
// restored row up on next refresh; we deliberately do not splice
// it into `history` directly here, because that store is loaded
// by the page-store init path and inserting a partial DTO would
// drift the in-memory shape from the SQLite source of truth.
trashItems = trashItems.filter((t) => t.id !== id);
} catch (err) {
toasts.error(
"Restore failed",
typeof err === "string" ? err : String(err),
);
} finally {
restoringId = null;
}
}
// Formats an ISO timestamp for display in the Trash list. We surface
// the transcript's `createdAt` rather than the deletion timestamp
// because `deleted_at` is not currently in the TranscriptDto shape;
// adding it is out of scope for this fix. Falls back to the raw
// string if Date can't parse it so we never display "NaN".
function formatTrashTimestamp(iso) {
if (!iso) return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
} catch {
return iso;
}
}
// Switching to trash view loads the list once. Switching back to live
// discards the trash data so a stale list doesn't reappear on toggle.
$effect(() => {
if (viewMode === "trash") {
void loadTrash();
} else {
trashItems = [];
trashError = "";
}
});
// Phase 9 bulk selection. Selection state is frontend-only and resets
// on page refresh by design.
let selected = $state(new Set());
let playingId = $state(null);
let audioEl = $state(null);
let currentTime = $state(0);
let duration = $state(0);
let playbackRate = $state(1);
let animFrameId = null;
let listEl = $state(null);
let containerHeight = $state(0);
let containerWidth = $state(0);
let scrollTop = $state(0);
// Inline two-click confirmation state for destructive bulk actions.
// First click arms the button (morphs into "Confirm" + "Cancel"), a
// second click confirms. Auto-cancels after CONFIRM_TIMEOUT_MS so a
// user who walked away doesn't return to a primed delete button.
// Replaces the native window.confirm() dialog, which broke the warm
// UI tone with an OS-styled modal.
//
// NOTE: clearAll (delete-everything) is gated by a stronger
// type-the-word-DELETE modal — see CLEAR_ALL_CONFIRM_WORD below.
// The arm-confirm pattern is retained for bulkDelete (selection
// subset) where the failure mode is smaller.
const CONFIRM_TIMEOUT_MS = 4000;
let bulkDeleteArmed = $state(false);
let bulkDeleteArmTimer: ReturnType<typeof setTimeout> | null = null;
// Type-the-word modal state for clearAll. Replaces the 4-second
// arm-confirm because Clear All wipes every transcript at once;
// mis-clicks under the prior pattern soft-deleted the entire
// history. The user must type the word DELETE exactly
// (case-sensitive) before the Confirm button activates.
const CLEAR_ALL_CONFIRM_WORD = "DELETE";
let clearAllModalOpen = $state(false);
let clearAllConfirmInput = $state("");
let clearAllInputEl = $state(null);
let clearAllInProgress = $state(false);
let clearAllConfirmValid = $derived(
clearAllConfirmInput === CLEAR_ALL_CONFIRM_WORD,
);
function openClearAllModal() {
clearAllConfirmInput = "";
clearAllModalOpen = true;
// Focus the input on the next tick — Svelte 5 binds run after the
// {#if} branch mounts, so a microtask is enough.
queueMicrotask(() => {
if (clearAllInputEl) clearAllInputEl.focus();
});
}
function closeClearAllModal() {
if (clearAllInProgress) return;
clearAllModalOpen = false;
clearAllConfirmInput = "";
}
function armBulkDelete() {
bulkDeleteArmed = true;
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
bulkDeleteArmTimer = setTimeout(() => { bulkDeleteArmed = false; }, CONFIRM_TIMEOUT_MS);
}
function disarmBulkDelete() {
bulkDeleteArmed = false;
if (bulkDeleteArmTimer) { clearTimeout(bulkDeleteArmTimer); bulkDeleteArmTimer = null; }
}
// Screen-reader announcement for the inline-confirm flows. The button
// morphs into Confirm/Cancel pills, which is silent to assistive tech;
// this derived feeds an aria-live region near the page root so the
// armed state is announced as it arms and disarms.
let confirmAnnouncement = $derived(
bulkDeleteArmed
? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.`
: ""
);
onDestroy(() => {
stopPlayback();
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
});
function itemHasStar(h) {
if (Array.isArray(h?.segments)) {
return h.segments.some((s) => s?.starred);
}
return Boolean(h?.starred);
}
function itemAllTags(h) {
const auto = deriveAutoTags(h);
const manual = Array.isArray(h?.manualTags) ? h.manualTags : [];
return [...auto, ...manual];
}
function parseTagFilter(q) {
// Matches `tag:value` anywhere in the query; returns { tag, rest }.
const match = q.match(/(?:^|\s)tag:([^\s]+)/i);
if (!match) return { tag: null, rest: q };
const rest = (q.slice(0, match.index) + " " + q.slice(match.index + match[0].length)).trim();
return { tag: match[1].toLowerCase(), rest };
}
let searchParsed = $derived(parseTagFilter(searchQuery || ""));
let allTags = $derived.by(() => {
const counts = new Map();
for (const h of history) {
for (const tag of itemAllTags(h)) {
const t = tag.toLowerCase();
counts.set(t, (counts.get(t) || 0) + 1);
}
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag, count]) => ({ tag, count }));
});
let filtered = $derived.by(() => {
let items = history;
if (showStarredOnly) items = items.filter(itemHasStar);
if (activeTagFilter) {
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === activeTagFilter));
}
if (searchParsed.tag) {
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === searchParsed.tag));
}
const q = searchParsed.rest.trim().toLowerCase();
if (q) {
items = items.filter((h) => (
(h.text && h.text.toLowerCase().includes(q)) ||
(h.preview && h.preview.toLowerCase().includes(q)) ||
(h.source && h.source.toLowerCase().includes(q)) ||
(h.title && h.title.toLowerCase().includes(q))
));
}
return items;
});
let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13));
let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13));
function compactPreviewText(item) {
// The compact row shows the title (or a placeholder). The transcript
// body lives in the expanded drawer so the two are visually distinct.
return item.title?.trim() || "Untitled";
}
let compactPreviews = $derived.by(() => {
return filtered.map((item) => {
const text = compactPreviewText(item);
if (!text) {
return {
text: "",
fullText: "",
height: historyLineHeight,
};
}
if (containerWidth <= 0) {
const fallbackText = text.length > 80 ? `${text.slice(0, 79)}…` : text;
return {
text: fallbackText,
fullText: text,
height: historyLineHeight,
};
}
const reservedWidth =
HISTORY_ROW_HORIZONTAL_PADDING +
HISTORY_ICON_WIDTH +
(item.duration ? HISTORY_DURATION_WIDTH : 0) +
HISTORY_SOURCE_WIDTH +
HISTORY_DATE_WIDTH +
HISTORY_CHEVRON_WIDTH +
HISTORY_ROW_GAP * (item.duration ? 5 : 4);
const textWidth = Math.max(HISTORY_PREVIEW_MIN_WIDTH, containerWidth - reservedWidth);
const preview = clampTextLines(
text,
historyTextFont,
textWidth,
historyLineHeight,
HISTORY_PREVIEW_LINES,
);
return {
text: preview.text,
fullText: text,
height: preview.height,
};
});
});
let itemHeights = $derived.by(() => {
if (!filtered.length) return [];
const textWidth = Math.max(0, containerWidth - 32);
return filtered.map((item, index) => {
const compactHeight = Math.max(
COLLAPSED_ROW_MIN_HEIGHT,
(compactPreviews[index]?.height || historyLineHeight) + COLLAPSED_ROW_VERTICAL_PADDING,
);
let height = compactHeight;
if (expandedId === item.id) {
height += EXPANDED_BASE_HEIGHT + EXPANDED_TITLE_INPUT_HEIGHT + EXPANDED_TAGS_ROW_HEIGHT;
const transcriptHeight = item.text && textWidth > 0
? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height
: historyLineHeight;
height += transcriptHeight;
if (item.audioPath && playingId === item.id) {
height += AUDIO_PLAYER_HEIGHT;
}
}
return height;
});
});
let cumulativeOffsets = $derived(buildCumulativeOffsets(itemHeights));
let totalHeight = $derived(
cumulativeOffsets.length > 0 ? cumulativeOffsets[cumulativeOffsets.length - 1] : 0
);
let visibleRange = $derived.by(() =>
findVisibleRange(
cumulativeOffsets,
filtered.length,
scrollTop,
containerHeight,
BUFFER_COUNT,
)
);
let visibleItems = $derived.by(() => {
const items = [];
for (let i = visibleRange.start; i < visibleRange.end; i++) {
items.push({
item: filtered[i],
top: cumulativeOffsets[i],
height: itemHeights[i],
preview: compactPreviews[i],
});
}
return items;
});
// Wipe every live transcript. Soft-delete via the SQLite backend so
// the user has a 30-day window to restore from Trash. Gated behind
// the type-the-word-DELETE modal — the prior 4-second arm-confirm
// let a single mis-click destroy the whole history.
async function clearAll() {
if (!clearAllConfirmValid || clearAllInProgress) return;
clearAllInProgress = true;
const ids = history.map((entry) => entry.id);
history.splice(0);
expandedId = null;
stopPlayback();
let failed = 0;
for (const id of ids) {
try {
await invoke("delete_transcript", { id: String(id) });
} catch (err) {
failed++;
console.warn("clearAll: SQLite delete failed", id, err);
}
}
if (failed > 0) {
toasts.error(
"Some history entries could not be deleted",
`${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`,
);
}
clearAllInProgress = false;
clearAllModalOpen = false;
clearAllConfirmInput = "";
}
function toggleExpand(id) {
expandedId = expandedId === id ? null : id;
}
function onListScroll(e) {
scrollTop = e.target.scrollTop;
}
function copyItem(item) {
navigator.clipboard.writeText(item.text).catch(() => {
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
});
}
function removeItem(item) {
const idx = history.indexOf(item);
if (idx !== -1) {
deleteFromHistory(idx);
if (expandedId === item.id) expandedId = null;
if (playingId === item.id) stopPlayback();
}
}
function togglePlay(item) {
if (playingId === item.id) {
if (audioEl && !audioEl.paused) {
audioEl.pause();
cancelAnimationFrame(animFrameId);
} else if (audioEl) {
audioEl.play();
tickTime();
}
return;
}
stopPlayback();
try {
const src = convertFileSrc(item.audioPath);
const audio = new Audio(src);
audio.playbackRate = playbackRate;
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => { stopPlayback(); };
audio.onerror = () => { stopPlayback(); };
audio.play();
audioEl = audio;
playingId = item.id;
tickTime();
} catch {
stopPlayback();
}
}
function stopPlayback() {
if (audioEl) { audioEl.pause(); audioEl.src = ""; audioEl = null; }
playingId = null;
currentTime = 0;
duration = 0;
cancelAnimationFrame(animFrameId);
}
function tickTime() {
if (audioEl) {
currentTime = audioEl.currentTime;
if (!audioEl.paused) {
animFrameId = requestAnimationFrame(tickTime);
}
}
}
let seekTimeout = null;
function seekTo(e) {
const val = parseFloat(e.target.value);
currentTime = val; // update UI immediately
clearTimeout(seekTimeout);
seekTimeout = setTimeout(() => {
if (audioEl) audioEl.currentTime = val;
}, 50);
}
function setSpeed(speed) {
playbackRate = speed;
if (audioEl) audioEl.playbackRate = speed;
}
async function openViewer(item) {
// Hand off the transcript ID only — the viewer window fetches the full
// row from SQLite via `get_transcript`. Previously the entire DTO
// (text + segments + tags) was stuffed into localStorage, putting
// potentially MB-scale PII in a place readable by any same-origin
// script.
const handoff = JSON.stringify({ id: String(item.id) });
try {
localStorage.setItem("lumotia_viewer_item", handoff);
localStorage.setItem("lumotia_viewer_mode", "view");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("lumotia_viewer_item", handoff);
localStorage.setItem("lumotia_viewer_mode", "view");
window.open("/viewer", "_blank", "width=600,height=700");
}
}
function handleAddTagKey(e, item) {
if (e.key !== "Enter" && e.key !== ",") return;
e.preventDefault();
const raw = e.target.value || "";
const next = normaliseTag(raw);
if (!next) return;
const existing = new Set((item.manualTags || []).map((t) => normaliseTag(t)));
if (existing.has(next)) {
e.target.value = "";
return;
}
item.manualTags = [...(item.manualTags || []), next];
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
e.target.value = "";
}
function removeManualTag(item, tag) {
const t = normaliseTag(tag);
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
}
// Phase 9 LLM content tagging.
let tagging = $state(new Set());
let bulkTagging = $state(false);
let bulkTaggingProgress = $state("");
async function tagRow(item) {
if (tagging.has(item.id)) return;
const next = new Set(tagging);
next.add(item.id);
tagging = next;
try {
const result = await invoke("extract_content_tags_cmd", {
transcript: item.text || "",
});
const r = result;
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
} catch (err) {
toasts.error("Tagging failed", String(err));
} finally {
const rest = new Set(tagging);
rest.delete(item.id);
tagging = rest;
}
}
function promoteLlmTag(item, tag) {
const normalised = normaliseTag(tag);
const manual = new Set((item.manualTags || []).map(normaliseTag));
manual.add(normalised);
item.manualTags = [...manual];
item.llmTags = (item.llmTags || []).filter((t) => normaliseTag(t) !== normalised);
saveTranscriptMeta(item.id, {
manualTags: item.manualTags,
llmTags: item.llmTags,
});
}
async function tagAllUntagged() {
if (bulkTagging) return;
bulkTagging = true;
try {
const untagged = history.filter((i) => !i.llmTags || i.llmTags.length === 0);
let done = 0;
for (const item of untagged) {
done += 1;
bulkTaggingProgress = `${done} / ${untagged.length}`;
try {
const result = await invoke("extract_content_tags_cmd", {
transcript: item.text || "",
});
const r = result;
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
} catch (err) {
console.error("bulk tag failed for", item.id, err);
}
}
toasts.success(`Tagged ${untagged.length} transcript${untagged.length === 1 ? "" : "s"}`);
} finally {
bulkTagging = false;
bulkTaggingProgress = "";
}
}
async function exportMarkdown(item) {
await saveTranscriptAsMarkdown(item);
}
// Phase 9 bulk selection handlers.
function toggleSelected(id) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
}
function clearSelection() {
selected = new Set();
}
function selectAllVisible() {
selected = new Set(filtered.map((i) => i.id));
}
let selectedItems = $derived(history.filter((i) => selected.has(i.id)));
async function bulkExport() {
const items = selectedItems;
if (items.length === 0) return;
await exportTranscriptsToDir(items);
clearSelection();
}
async function bulkDelete() {
if (selected.size === 0) return;
disarmBulkDelete();
for (const id of selected) deleteFromHistoryById(id);
clearSelection();
}
// Phase 9 keyboard shortcuts: Escape clears selection, Cmd/Ctrl+A
// selects all visible when the History list owns focus. The
// listEl?.contains check prevents hijacking Cmd+A inside text inputs
// outside the list (e.g. the search box, inline title input).
$effect(() => {
function onKey(e) {
if (e.key === "Escape" && selected.size > 0) {
clearSelection();
e.preventDefault();
return;
}
if ((e.key === "a" || e.key === "A") && (e.metaKey || e.ctrlKey)) {
const target = document.activeElement;
const inText =
target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA");
if (!inText && listEl && listEl.contains(target)) {
selectAllVisible();
e.preventDefault();
}
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
async function openEditor(item) {
// Hand off the transcript ID only; the viewer hydrates from SQLite
// (see openViewer above for the rationale).
const handoff = JSON.stringify({ id: String(item.id) });
try {
localStorage.setItem("lumotia_viewer_item", handoff);
localStorage.setItem("lumotia_viewer_mode", "edit");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("lumotia_viewer_item", handoff);
localStorage.setItem("lumotia_viewer_mode", "edit");
window.open("/viewer", "_blank", "width=600,height=700");
}
}
$effect(() => {
if (!listEl) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
}
});
ro.observe(listEl);
return () => ro.disconnect();
});
$effect(() => {
if (expandedId && !filtered.some((item) => item.id === expandedId)) {
expandedId = null;
}
});
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Off-screen live region for inline-confirm announcements. Sits at
the page root so it persists while the morphed Confirm/Cancel
pills mount and unmount in the header and bulk toolbar. -->
<div
class="sr-only"
aria-live="polite"
aria-atomic="true"
>{confirmAnnouncement}</div>
<!-- Header -->
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
<h2 class="font-display text-[26px] italic text-text">History</h2>
<span class="text-[12px] text-text-secondary mt-1">
{viewMode === "trash" ? `${trashItems.length} in Trash` : `${history.length} saved`}
</span>
<div
class="inline-flex items-center rounded-lg border border-border-subtle p-0.5 bg-bg-elevated"
role="tablist"
aria-label="History view"
>
<button
class="text-[12px] px-2.5 py-1 rounded-md
{viewMode === 'live' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
role="tab"
aria-selected={viewMode === "live"}
onclick={() => (viewMode = "live")}
>Live</button>
<button
class="text-[12px] px-2.5 py-1 rounded-md
{viewMode === 'trash' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
role="tab"
aria-selected={viewMode === "trash"}
onclick={() => (viewMode = "trash")}
>Trash</button>
</div>
<div class="flex-1"></div>
{#if viewMode === "live"}
<button
class="inline-flex items-center gap-1.5 btn-md rounded-lg
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
onclick={() => (showStarredOnly = !showStarredOnly)}
aria-pressed={showStarredOnly}
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
>
<Star size={14} aria-hidden="true" />
<span class="text-[12px]">Starred</span>
</button>
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5"
onclick={tagAllUntagged}
disabled={bulkTagging}
title="Run AI content tagging across every untagged transcript"
>
<Tag size={13} aria-hidden="true" />
<span class="text-[12px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
</button>
{/if}
{#if history.length > 0}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
onclick={openClearAllModal}
>
Clear All
</button>
{/if}
{:else}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50"
onclick={loadTrash}
disabled={trashLoading}
title="Reload Trash"
>
<span class="text-[12px]">{trashLoading ? "Refreshing…" : "Refresh"}</span>
</button>
{/if}
</div>
<!-- Type-the-word DELETE modal for clearAll. Replaces the 4-second
inline arm-confirm; an absent-minded double-tap on the prior
Confirm pill wiped every transcript at once. Backed by the
soft-delete path so a successful Clear All still leaves a 30-day
restore window in the Trash view. -->
{#if clearAllModalOpen}
<!-- Backdrop. A keyboard user reaches the modal via Tab; the
escape-to-close handler lives on the inner card. The
non-interactive role and presentation role on the backdrop
keep it out of the assistive-tech tree as a clickable
element while still allowing the click-outside-to-close
affordance for sighted users. -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
role="presentation"
onclick={closeClearAllModal}
onkeydown={(e) => { if (e.key === 'Escape') closeClearAllModal(); }}
>
<div
class="bg-bg-card border border-border-subtle rounded-xl shadow-2xl max-w-md w-[92%] p-6"
role="dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="clear-all-title"
aria-describedby="clear-all-description"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeClearAllModal(); }}
>
<h3 id="clear-all-title" class="font-display text-[18px] text-text mb-2">
Clear all history?
</h3>
<p id="clear-all-description" class="text-[13px] text-text-secondary mb-4">
This will move every transcript ({history.length}) to Trash. Items in Trash
are kept for 30 days, then permanently deleted.
</p>
<label for="clear-all-confirm-input" class="block text-[12px] text-text-secondary mb-1.5">
Type <span class="font-mono text-text">DELETE</span> to confirm.
</label>
<input
id="clear-all-confirm-input"
bind:this={clearAllInputEl}
bind:value={clearAllConfirmInput}
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text font-mono
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="DELETE"
autocomplete="off"
spellcheck="false"
data-no-transition
onkeydown={(e) => {
if (e.key === 'Enter' && clearAllConfirmValid && !clearAllInProgress) {
clearAll();
}
}}
/>
<div class="mt-5 flex items-center justify-end gap-2">
<button
class="btn-md rounded-lg text-text-secondary hover:text-text hover:bg-hover disabled:opacity-50"
onclick={closeClearAllModal}
disabled={clearAllInProgress}
>Cancel</button>
<button
class="btn-md rounded-lg bg-danger/10 border border-danger/30 text-danger hover:bg-danger/20
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-danger/10"
onclick={clearAll}
disabled={!clearAllConfirmValid || clearAllInProgress}
>{clearAllInProgress ? "Clearing…" : "Clear all"}</button>
</div>
</div>
</div>
{/if}
{#if viewMode === "live"}
<!-- Search -->
<div class="px-7 pb-3">
<LumotiaCard tone="subtle">
<div class="flex items-center gap-3 px-4 py-2.5">
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
<input
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary"
placeholder="Search all transcripts... (try tag:meetings)"
bind:value={searchQuery}
data-no-transition
/>
{#if searchQuery}
<span class="text-[12px] text-text-secondary mr-2">{filtered.length} results</span>
<button
class="text-[12px] text-text-secondary hover:text-text"
onclick={() => searchQuery = ""}
>Clear</button>
{/if}
</div>
</LumotiaCard>
</div>
<!-- Tag chip filter bar -->
{#if allTags.length > 0}
<div class="px-7 pb-3 flex items-center gap-1.5 flex-wrap">
<span class="text-[12px] uppercase tracking-wider text-text-secondary mr-1">Tags</span>
{#if activeTagFilter}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-accent text-[12px] text-accent bg-accent/10"
onclick={() => (activeTagFilter = null)}
aria-pressed="true"
title="Clear tag filter"
>
{activeTagFilter}
<span class="text-[12px] leading-none">×</span>
</button>
{:else}
{#each allTags.slice(0, 7) as t (t.tag)}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-subtle text-[12px] text-text-secondary hover:border-accent hover:text-accent"
onclick={() => (activeTagFilter = t.tag)}
aria-pressed={activeTagFilter === t.tag}
>
{t.tag}
<span class="text-[12px] text-text-secondary">{t.count}</span>
</button>
{/each}
{/if}
</div>
{/if}
<!-- Phase 9 bulk-action toolbar. Surfaces only when selection is
non-empty, so the unselected default state is unchanged. -->
{#if selected.size > 0}
<div
class="mx-7 mb-3 flex items-center gap-3 px-4 py-2 rounded-lg border border-border-subtle bg-bg-elevated text-[12px]"
role="toolbar"
aria-label="Bulk actions for selected transcripts"
>
<span class="text-text-tertiary">{selected.size} selected</span>
<button
class="text-text hover:underline"
onclick={selectAllVisible}
>Select all</button>
<button
class="text-text-tertiary hover:text-text"
onclick={clearSelection}
>Clear</button>
<div class="ml-auto flex items-center gap-4">
<button
class="text-text hover:underline"
onclick={bulkExport}
>Export selected</button>
{#if bulkDeleteArmed}
<span class="text-text-secondary">Delete {selected.size} {selected.size === 1 ? 'transcript' : 'transcripts'}?</span>
<button
class="text-danger font-medium hover:underline"
onclick={bulkDelete}
>Confirm</button>
<button
class="text-text-tertiary hover:text-text hover:underline"
onclick={disarmBulkDelete}
>Cancel</button>
{:else}
<button
class="text-danger hover:underline"
onclick={armBulkDelete}
>Delete selected</button>
{/if}
</div>
</div>
{/if}
<!-- History list -->
<div class="flex-1 px-7 pb-4 min-h-0">
<LumotiaCard classes="h-full flex flex-col overflow-hidden">
{#if filtered.length === 0}
<LumotiaEmptyState
icon={Clock}
message={searchQuery ? "No matching transcripts" : "Your transcriptions will be saved here"}
/>
{:else}
<div bind:this={listEl} class="flex-1 overflow-y-auto" onscroll={onListScroll}>
<div class="relative" style="height: {totalHeight}px">
{#each visibleItems as { item, top, height, preview } (item.id)}
<div class="absolute left-0 right-0" style="top: {top}px; min-height: {height}px">
<!-- Compact row -->
<div
class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle bg-bg-card hover:bg-hover cursor-pointer {selected.has(item.id) ? 'bg-accent/5' : ''}"
style="transition-duration: var(--duration-ui)"
onclick={() => toggleExpand(item.id)}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpand(item.id); } }}
role="button"
tabindex="0"
aria-expanded={expandedId === item.id}
>
<!-- Phase 9 bulk-select checkbox. Stays unobtrusive
until checked or hovered; e.stopPropagation keeps
the click off the row-expand toggle. -->
<input
type="checkbox"
class="w-3.5 h-3.5 flex-shrink-0 accent-accent cursor-pointer opacity-70 hover:opacity-100 {selected.has(item.id) ? 'opacity-100' : ''}"
aria-label={`Select transcript ${item.title || item.id}`}
checked={selected.has(item.id)}
onclick={(e) => { e.stopPropagation(); toggleSelected(item.id); }}
onkeydown={(e) => { if (e.key === " " || e.key === "Enter") e.stopPropagation(); }}
/>
<!-- Play button (if audio exists) -->
{#if item.audioPath}
<button
class="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0
{playingId === item.id ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-accent hover:bg-accent/20'}"
onclick={(e) => { e.stopPropagation(); togglePlay(item); }}
aria-label={playingId === item.id && audioEl && !audioEl.paused ? "Pause" : "Play"}
>
{#if playingId === item.id && audioEl && !audioEl.paused}
<Pause size={10} aria-hidden="true" />
{:else}
<Play size={10} class="ml-0.5" aria-hidden="true" />
{/if}
</button>
{:else}
<div class="w-6 h-6 flex-shrink-0"></div>
{/if}
<!-- Title / preview text (truncated) -->
<div class="flex-1 min-w-0">
<p
class="text-[13px] text-text whitespace-pre-wrap break-words"
style="line-height: {historyLineHeight}px"
title={preview?.fullText || ""}
>
{preview?.text || compactPreviewText(item)}
</p>
</div>
<!-- Duration -->
{#if item.duration}
<span class="text-[12px] text-text-secondary flex-shrink-0 tabular-nums">
{formatDuration(item.duration)}
</span>
{/if}
<!-- Source icon -->
<span class="flex-shrink-0 text-text-tertiary" title={item.source}>
{#if item.source && item.source.toLowerCase().includes("file")}
<FileText size={14} aria-hidden="true" />
{:else}
<Mic size={14} aria-hidden="true" />
{/if}
</span>
<!-- Date (right-aligned) -->
<span class="text-[12px] text-text-secondary flex-shrink-0 text-right min-w-[90px]">
{item.date}
</span>
<!-- Expand chevron -->
<ChevronDown
size={14}
class="text-text-tertiary flex-shrink-0 {expandedId === item.id ? 'rotate-180' : ''}"
style="transition: transform var(--duration-ui)"
aria-hidden="true"
/>
</div>
<!-- Expanded detail -->
{#if expandedId === item.id}
<div class="px-4 py-4 bg-bg-elevated border-b border-border-subtle">
<!-- Audio player (if playing this item) -->
{#if item.audioPath && playingId === item.id}
<div class="flex items-center gap-3 mb-4 px-1">
<span class="text-[12px] text-text-secondary tabular-nums w-[80px]">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<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
onclick={(e) => e.stopPropagation()}
/>
<div class="flex gap-0.5">
{#each PLAYBACK_SPEEDS as speed}
<button
class="text-[12px] 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={(e) => { e.stopPropagation(); setSpeed(speed); }}
>{speed}x</button>
{/each}
</div>
</div>
{/if}
<!-- Inline title input -->
<input
type="text"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
text-text placeholder:text-text-tertiary focus:border-accent mb-3"
placeholder="Name this transcript..."
value={item.title || ""}
oninput={(e) => { item.title = e.target.value; }}
onblur={() => renameHistoryEntry(item.id, { title: (item.title || "").trim() }).catch(() => {})}
onclick={(e) => e.stopPropagation()}
data-no-transition
/>
<!-- Tags: auto + manual -->
<div class="flex items-center gap-1.5 flex-wrap mb-3" onclick={(e) => e.stopPropagation()} role="presentation">
{#each deriveAutoTags(item) as t (t)}
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[12px] bg-bg-input text-text-secondary border border-border-subtle"
title="Auto-generated tag"
>{t}</span>
{/each}
{#each (item.manualTags || []) as t (t)}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[12px] bg-accent/10 text-accent border border-accent/30">
{t}
<button
class="text-[12px] leading-none hover:text-danger"
onclick={() => removeManualTag(item, t)}
title="Remove tag"
aria-label="Remove tag {t}"
>×</button>
</span>
{/each}
<!-- Phase 9 LLM-generated tags. Dashed border + italic
distinguishes from manual chips. Click to promote
into manualTags. -->
{#each (item.llmTags || []) as t (t)}
<button
class="inline-flex items-center px-2 py-0.5 rounded-full text-[12px] italic border border-dashed border-border-subtle text-text-secondary hover:text-text hover:border-accent"
onclick={() => promoteLlmTag(item, t)}
title="Click to keep as a manual tag"
aria-label={`Promote ${t} to manual tag`}
>{t}</button>
{/each}
<input
type="text"
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[12px]
text-text placeholder:text-text-tertiary focus:border-accent w-[110px]"
placeholder="+ add tag"
onkeydown={(e) => handleAddTagKey(e, item)}
data-no-transition
/>
</div>
<!-- Full transcript -->
<p class="text-[13px] text-text whitespace-pre-wrap mb-4" style="line-height: {historyLineHeight}px">{item.text}</p>
<!-- Action buttons -->
<div class="flex items-center gap-2 flex-wrap">
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Copy</button>
<button
class="inline-flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); openEditor(item); }}
title="Open transcript in a popout editor"
>
Edit
<ExternalLink size={11} aria-hidden="true" />
</button>
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
title="Export this transcript as a Markdown file with YAML frontmatter"
>Export .md</button>
<button
class="inline-flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text disabled:opacity-50"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); tagRow(item); }}
disabled={tagging.has(item.id)}
title="Generate AI content tags (topic and intent)"
aria-label="Generate content tags with AI"
>
<Tag size={11} aria-hidden="true" />
{tagging.has(item.id) ? "Tagging…" : "Tag"}
</button>
{#if item.audioPath && item.segments && item.segments.length > 0}
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); openViewer(item); }}
>Open viewer</button>
{/if}
<div class="flex-1"></div>
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-danger"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); removeItem(item); }}
>Delete</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</LumotiaCard>
</div>
{:else}
<!-- Trash view. Lists soft-deleted transcripts (deleted_at IS NOT
NULL) with a Restore button per row. Permanently-delete-from-
trash is intentionally deferred — the 30-day startup purge
handles hard-removal until the UI lands. Audio files may
already have been removed by delete_transcript's best-effort
filesystem cleanup, so restored text + metadata may surface
without playable audio. -->
<div class="px-7 pb-3">
<div class="text-[12px] text-text-secondary italic">
Trashed items are kept for 30 days, then permanently deleted on next app start.
</div>
</div>
<div class="flex-1 px-7 pb-4 min-h-0">
<LumotiaCard classes="h-full flex flex-col overflow-hidden">
{#if trashLoading && trashItems.length === 0}
<LumotiaEmptyState
icon={Clock}
message="Loading Trash…"
/>
{:else if trashError && trashItems.length === 0}
<LumotiaEmptyState
icon={Clock}
message={`Failed to load Trash: ${trashError}`}
/>
{:else if trashItems.length === 0}
<LumotiaEmptyState
icon={Clock}
message="Trash is empty"
/>
{:else}
<div class="flex-1 overflow-y-auto">
{#each trashItems as item (item.id)}
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle bg-bg-card">
<div class="flex-1 min-w-0">
<div class="text-[13px] text-text truncate">
{item.title || (item.text ? item.text.slice(0, 80) : "Untitled")}
</div>
<div class="text-[12px] text-text-tertiary mt-0.5">
Created {formatTrashTimestamp(item.createdAt)}
</div>
</div>
<button
class="text-[12px] px-3 py-1.5 rounded-lg bg-accent/10 border border-accent/30 text-accent
hover:bg-accent/20 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => restoreFromTrash(item.id)}
disabled={restoringId === item.id}
title="Restore this transcript to the Live list"
>{restoringId === item.id ? "Restoring…" : "Restore"}</button>
</div>
{/each}
</div>
{/if}
</LumotiaCard>
</div>
{/if}
</div>