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.
1559 lines
63 KiB
Svelte
1559 lines
63 KiB
Svelte
<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}
|
||
· {formatDuration(item.duration)}
|
||
{/if}
|
||
· {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>
|