Files
Lumotia/src/lib/pages/HistoryPage.svelte
Jake 3810fdda43 v0.3 Phase 5f.1 polish: date groups, useful previews, quiet row actions
Acts on the polish-pass brief returned for 5f. Touches the quietware
branch only; v0.2 fallback (and compactPreviewText, used by its
row-height measurer) is left intact.

  Date groups       liveGroups derived walks `filtered` and breaks at
                    local-calendar-day boundaries. dateGroupLabel
                    formats Today / Yesterday / D MMM / D MMM YYYY.
                    Section headers are 11px mono uppercase, separated
                    by the same divide-y the rows use.

  Row hierarchy     New quietRowPreview returns a real transcript
                    snippet (item.text → item.preview → empty). The
                    title repeat in 5f was compactPreviewText returning
                    the title under the title; that helper stays for
                    v0.2's height math, the quietware row uses the new
                    one. Falls back to "No preview available" in-template.

  Row meta-line     HH:MM · duration · Dictation|File. Date moved to
                    the group header — repeating it per row wasted
                    hierarchy.

  Row actions       Open stays visible (tertiary), other actions moved
                    behind a quiet LumotiaIconButton(MoreHorizontal)
                    trigger that opens LumotiaMenu with Copy /
                    Export markdown / Move to Trash. Trigger is
                    always present (not hover-only) so keyboard
                    focus works.

  Search alignment  Dropped max-w-2xl. Search now spans the same
                    content gutter as the rows so the right edge
                    aligns with where the actions column begins —
                    intentional, not arbitrary.

  Trash rows        Same row anatomy. Restore visible. No overflow
                    menu — perm-delete-from-trash UI is deferred per
                    the existing comment (TRANSCRIPT_TRASH_RETENTION_DAYS
                    handles hard-removal). Meta-line shows
                    "Deleted …  ·  Auto-purges after 30 days".

Date-grouping formula (per CLAUDE.md rule):
  same local-calendar day as now → "Today"
  previous local-calendar day    → "Yesterday"
  same local-calendar year       → "D MMM"
  older                          → "D MMM YYYY"
Day boundaries are strict local midnight, not 24-hour windows. Items
without savedAt fall into an "undated" bucket.

npm run check: 0 errors.
2026-05-15 20:37:51 +01:00

1559 lines
63 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 LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
// v0.3 Phase 5f — quietware History layout.
import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte";
import LumotiaMenu from "$lib/ui/LumotiaMenu.svelte";
import LumotiaIconButton from "$lib/ui/LumotiaIconButton.svelte";
import { onMount } from "svelte";
import { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag, MoreHorizontal, Copy, Trash2, FileDown } 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.`
: ""
);
// v0.3 Phase 5f — quietware layout switch. Same observer pattern
// as Dictation / Files. v0.2 surface unchanged without quietware.
let isQuietware = $state(false);
let quietwareObserver: MutationObserver | null = null;
onMount(() => {
if (typeof document === "undefined") return;
const sync = () => {
isQuietware = document.documentElement.dataset.design === "quietware";
};
sync();
quietwareObserver = new MutationObserver(sync);
quietwareObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-design"],
});
});
onDestroy(() => {
quietwareObserver?.disconnect();
quietwareObserver = null;
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";
}
// v0.3 Phase 5f polish — quietware row helpers. Kept separate from
// compactPreviewText so the v0.2 fallback's row-height measurement
// (which reads compactPreviews) is untouched.
/** Two-line transcript snippet for the quietware row sub-line. Falls
* through item.text → item.preview → empty string ("No preview…"
* rendering is the template's job, not this helper's). Stripped of
* leading whitespace so previews don't start mid-pause. */
function quietRowPreview(item) {
const raw = (item?.text || item?.preview || "").replace(/\s+/g, " ").trim();
return raw;
}
/** Source label for the quietware row meta-line. "Dictation" for
* mic-captured items, "File" for imports. Mirrors the icon column
* but lives in the meta-line so the row scans without colour cues. */
function quietRowSourceLabel(item) {
const s = (item?.source || "").toLowerCase();
return s.includes("file") ? "File" : "Dictation";
}
/** Just the HH:MM portion of a saved-at timestamp. The date sits in
* the group header above the row, so repeating it here would waste
* hierarchy. Uses en-GB 24h to match formatTimestamp. */
function quietRowTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
/** Calendar-day label for date-group headers. Formula:
* same local-calendar day as now → "Today"
* previous local-calendar day → "Yesterday"
* same local-calendar year → "D MMM" (e.g. "12 May")
* older → "D MMM YYYY" (e.g. "12 May 2025")
* Day boundaries are strictly local midnight, not 24-hour windows. */
function dateGroupLabel(iso) {
if (!iso) return "Undated";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "Undated";
const now = new Date();
const sameDay = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (sameDay(d, now)) return "Today";
const y = new Date(now);
y.setDate(now.getDate() - 1);
if (sameDay(d, y)) return "Yesterday";
const fmt = d.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
});
return fmt;
}
/** Stable bucket key for grouping. Local-calendar YYYY-MM-DD so the
* groups break at midnight, not on UTC date math. Items with no
* savedAt fall into an "undated" bucket that surfaces last. */
function dateGroupKey(iso) {
if (!iso) return "undated";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "undated";
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Walk `filtered` in order, breaking at calendar-day boundaries.
* Preserves the underlying sort so newest-first stays newest-first;
* groups appear in the order their first item appears. */
let liveGroups = $derived.by(() => {
const out = [];
let current = null;
for (const item of filtered) {
const iso = item.savedAt || item.timestamp;
const key = dateGroupKey(iso);
if (!current || current.key !== key) {
current = { key, label: dateGroupLabel(iso), items: [] };
out.push(current);
}
current.items.push(item);
}
return out;
});
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 {isQuietware ? '' : '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>
{#if isQuietware}
<!-- =================================================================
v0.3 Phase 5f — quietware History layout. Calm local archive.
Header "History" + Live / Trash tabs with counts
Primary surface search + date-grouped list / empty state
Row icon · title · 2-line preview · meta-line
Open (visible) + ⋯ overflow (Copy / Export
/ Move to Trash). Trash rows show Restore
visible; permanent-delete UI is deferred
(the 30-day purge handles hard-removal,
see TRANSCRIPT_TRASH_RETENTION_DAYS).
Mono footer saved count · trash count · local only
Reuses every existing state and handler: history (store),
trashItems, viewMode, searchQuery, filtered (derived),
copyItem, exportMarkdown, removeItem, openViewer,
restoreFromTrash. Bulk actions, audio playback, tag chips
and ClearAll modal stay accessible via the v0.2 fallback —
the quietware view focuses on the core archive flow.
================================================================= -->
<LumotiaPageSkeleton>
{#snippet header()}
<div class="flex items-center gap-4">
<div class="flex-1 min-w-0">
<h1 class="text-[18px] font-medium text-text leading-tight">History</h1>
<p class="text-[12px] text-text-secondary mt-0.5">
{viewMode === "trash" ? "Recover transcripts within 30 days." : "Search and reopen saved transcripts."}
</p>
</div>
<div
class="inline-flex items-center rounded-lg border border-border-subtle p-0.5 bg-bg-elevated text-[12px]"
role="tablist"
aria-label="History view"
>
<button
role="tab"
aria-selected={viewMode === "live"}
class="px-3 py-1 rounded-md transition-colors
{viewMode === 'live' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
onclick={() => (viewMode = "live")}
>Live <span class="text-text-tertiary font-mono">{history.length}</span></button>
<button
role="tab"
aria-selected={viewMode === "trash"}
class="px-3 py-1 rounded-md transition-colors
{viewMode === 'trash' ? 'bg-bg-card text-text' : 'text-text-secondary hover:text-text'}"
onclick={() => { viewMode = "trash"; loadTrash(); }}
>Trash <span class="text-text-tertiary font-mono">{trashItems.length}</span></button>
</div>
</div>
{/snippet}
{#snippet primary()}
<div class="h-full flex flex-col">
{#if trashError && viewMode === "trash"}
<div class="px-6 pt-4">
<LumotiaNotice tone="danger" slim dismissible onDismiss={() => trashError = ""}>
{trashError}
</LumotiaNotice>
</div>
{/if}
<!-- Search input. No max-width: spans the same content gutter
as the rows below so the right edge aligns with where the
row actions column begins. Intentional, not arbitrary. -->
<div class="px-6 pt-5 pb-3 border-b border-border-subtle">
<div class="relative">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
<input
type="search"
placeholder={viewMode === "trash" ? "Search trash" : "Search transcripts, #tags"}
bind:value={searchQuery}
class="w-full bg-bg-input border border-border-subtle rounded-lg pl-9 pr-9 py-2 text-[13px] text-text placeholder:text-text-tertiary
focus:outline-none focus:border-[var(--button-primary-bg)] focus:ring-[var(--wire-width-focus,2px)] focus:ring-[var(--button-primary-bg)]"
aria-label="Search history"
/>
</div>
</div>
<!-- Archive list. Reuses the existing derived `filtered` for
live mode and `trashItems` for trash mode. Live mode
renders date-bucketed groups (Today / Yesterday / D MMM)
so the surface scans as an archive rather than a flat
table; trash stays ungrouped because the relevant axis
there is days-until-purge, not save date. -->
<div class="flex-1 overflow-auto min-h-0">
{#if viewMode === "live"}
{#if filtered.length === 0}
<div class="h-full flex flex-col items-center justify-center text-center gap-3 text-text-tertiary px-12">
<FileText size={48} class="opacity-50" aria-hidden="true" />
<p class="font-display italic text-[24px] text-text">
{searchQuery ? "Nothing matched." : "Nothing saved yet."}
</p>
<p class="text-[13px] text-text-secondary">
{searchQuery ? "Try different keywords or clear the search." : "Saved dictations and file transcripts will appear here."}
</p>
</div>
{:else}
<ul class="pb-2">
{#each liveGroups as group (group.key)}
<li
class="px-6 pt-5 pb-2 text-[11px] uppercase tracking-wider text-text-tertiary font-mono"
aria-hidden="true"
>
{group.label}
</li>
<li>
<ul class="divide-y divide-border-subtle border-y border-border-subtle">
{#each group.items as item (item.id)}
{@const previewText = quietRowPreview(item)}
{@const sourceLabel = quietRowSourceLabel(item)}
<li class="group/row flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
{#if sourceLabel === "File"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title?.trim() || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
{previewText || "No preview available"}
</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
{quietRowTime(item.savedAt || item.timestamp)}
{#if item.duration}
&nbsp;·&nbsp;{formatDuration(item.duration)}
{/if}
&nbsp;·&nbsp;{sourceLabel}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
<LumotiaMenu
align="end"
items={[
{ value: "copy", label: "Copy transcript", icon: Copy, onSelect: () => copyItem(item) },
{ value: "export", label: "Export markdown", icon: FileDown, onSelect: () => exportMarkdown(item) },
{ kind: "separator" as const },
{ value: "delete", label: "Move to Trash", icon: Trash2, destructive: true, onSelect: () => removeItem(item) },
]}
>
{#snippet trigger()}
<LumotiaIconButton icon={MoreHorizontal} label="More actions" size="sm" />
{/snippet}
</LumotiaMenu>
</div>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
{:else}
<!-- Trash view -->
{#if trashLoading && trashItems.length === 0}
<div class="h-full flex items-center justify-center text-text-tertiary text-[13px]">Loading trash…</div>
{:else if trashItems.length === 0}
<div class="h-full flex flex-col items-center justify-center text-center gap-3 text-text-tertiary px-12">
<Clock size={48} class="opacity-50" aria-hidden="true" />
<p class="font-display italic text-[24px] text-text">Trash is empty.</p>
<p class="text-[13px] text-text-secondary">Deleted transcripts appear here for 30 days.</p>
</div>
{:else}
<ul class="divide-y divide-border-subtle">
{#each trashItems as item (item.id)}
{@const previewText = quietRowPreview(item)}
{@const sourceLabel = quietRowSourceLabel(item)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
{#if sourceLabel === "File"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title?.trim() || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
{previewText || "No preview available"}
</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
Deleted {formatTrashTimestamp(item.deleted_at || item.createdAt)} · Auto-purges after 30 days
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton
size="sm"
variant={restoringId === item.id ? "secondary" : "tertiary"}
disabled={restoringId === item.id}
onclick={() => restoreFromTrash(item.id)}
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>
</div>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{/snippet}
{#snippet metadata()}
<div class="flex items-center justify-end">
{history.length} saved · {trashItems.length} in trash · Local only
</div>
{/snippet}
</LumotiaPageSkeleton>
{:else}
<!-- 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}
{/if}
</div>