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:
@@ -44,6 +44,9 @@
|
|||||||
let showStarredOnly = $state(false);
|
let showStarredOnly = $state(false);
|
||||||
let activeTagFilter = $state(null); // null = all; string = tag value
|
let activeTagFilter = $state(null); // null = all; string = tag value
|
||||||
let expandedId = $state(null);
|
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 playingId = $state(null);
|
||||||
let audioEl = $state(null);
|
let audioEl = $state(null);
|
||||||
let currentTime = $state(0);
|
let currentTime = $state(0);
|
||||||
@@ -348,6 +351,67 @@
|
|||||||
await saveTranscriptAsMarkdown(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;
|
||||||
|
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) {
|
async function openEditor(item) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||||
@@ -454,6 +518,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- History list -->
|
||||||
<div class="flex-1 px-7 pb-4 min-h-0">
|
<div class="flex-1 px-7 pb-4 min-h-0">
|
||||||
<Card classes="h-full flex flex-col overflow-hidden">
|
<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">
|
<div class="absolute left-0 right-0" style="top: {top}px; min-height: {height}px">
|
||||||
<!-- Compact row -->
|
<!-- Compact row -->
|
||||||
<div
|
<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)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={() => toggleExpand(item.id)}
|
onclick={() => toggleExpand(item.id)}
|
||||||
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpand(item.id); } }}
|
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpand(item.id); } }}
|
||||||
@@ -477,6 +571,18 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-expanded={expandedId === item.id}
|
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) -->
|
<!-- Play button (if audio exists) -->
|
||||||
{#if item.audioPath}
|
{#if item.audioPath}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user