feat(phase9): History bulk select + bulk export

Slim leading checkbox on every row, tinted-row state when selected.
Bulk-action toolbar appears only when selection is non-empty: select
all (visible), clear, export selected (via exportTranscriptsToDir),
delete selected (single confirm). Esc clears selection. Cmd/Ctrl+A
selects all visible when focus is inside the list and not in a text
input. Stop-propagation on the checkbox keeps the click off the
row-expand toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 23:51:27 +01:00
parent eb6e291191
commit c26d82c26a

View File

@@ -44,6 +44,9 @@
let showStarredOnly = $state(false);
let activeTagFilter = $state(null); // null = all; string = tag value
let expandedId = $state(null);
// 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);
@@ -348,6 +351,67 @@
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;
const yes = confirm(
`Delete ${selected.size} transcript${selected.size === 1 ? "" : "s"}?`,
);
if (!yes) return;
for (const id of selected) deleteFromHistory(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) {
try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
@@ -454,6 +518,36 @@
</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 gap-4">
<button
class="text-text hover:underline"
onclick={bulkExport}
>Export selected</button>
<button
class="text-danger hover:underline"
onclick={bulkDelete}
>Delete selected</button>
</div>
</div>
{/if}
<!-- History list -->
<div class="flex-1 px-7 pb-4 min-h-0">
<Card classes="h-full flex flex-col overflow-hidden">
@@ -469,7 +563,7 @@
<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"
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); } }}
@@ -477,6 +571,18 @@
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-50 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