Files
WhispAssist/src/lib/views/MeetingsList.svelte
T

397 lines
11 KiB
Svelte

<script lang="ts">
// Left pane: meetings list + full-text search (Phase 2, FR-STORE-5; T8.2, FR-SEARCH-1).
import { meetings } from "../stores/meetings.svelte";
import { recording } from "../stores/recording.svelte";
import { api, type MeetingListItem, type SearchHit } from "../api";
import { open } from "@tauri-apps/plugin-dialog";
import { Search, Trash2, Download, RotateCcw } from "@lucide/svelte";
import { t } from "../i18n/index.svelte";
// Meeting status ("recording"/"transcribing"/"recovering"/"error") → its
// localized badge label. The status value itself is app vocabulary, not user
// content, so it gets keyed.
const statusLabel = (s: string) => t(`meetings.status.${s}`);
let query = $state("");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => meetings.search(query), 250);
}
// Tag/date filters (T8.3, FR-SEARCH-2) apply to the plain list, not
// full-text search — changing one drops out of search mode so the
// filtered list is immediately visible rather than hidden behind results.
// Writable derived (not $state+$effect) so this dropdown also reflects a
// tag filter triggered elsewhere (e.g. clicking a chip in the Tags panel),
// while still being directly editable via bind:value below.
let tagFilter = $derived(meetings.filter.tag ?? "");
let fromFilter = $state("");
let toFilter = $state("");
function toUnix(dateStr: string): number | undefined {
if (!dateStr) return undefined;
return Math.floor(new Date(dateStr).getTime() / 1000);
}
function onFilterChange() {
query = "";
meetings.search(null);
meetings.load({
tag: tagFilter || undefined,
from: toUnix(fromFilter),
to: toUnix(toFilter),
});
}
// Bulk export (T8.5, FR-STORE-4) — exports whatever the tag/date filters
// above currently select, so there's one filter mechanism, not two.
let bulkFormat = $state<"md" | "pdf" | "docx" | "bundle">("md");
let bulkExporting = $state(false);
let bulkResult = $state<string | null>(null);
async function bulkExport() {
const dir = await open({ directory: true });
if (typeof dir !== "string") return;
bulkExporting = true;
bulkResult = null;
try {
const count = await api.bulkExportMeetings(dir, bulkFormat, {
tag: tagFilter || undefined,
from: toUnix(fromFilter),
to: toUnix(toFilter),
});
bulkResult =
count === 1
? t("meetings.exported_one", { n: count })
: t("meetings.exported_many", { n: count });
} finally {
bulkExporting = false;
}
}
// Unified view: full-text results (with snippets) while searching, else the
// plain list. Same underlying fields either way.
let displayItems = $derived<(MeetingListItem | SearchHit)[]>(
meetings.searchResults ?? meetings.list,
);
function snippetOf(item: MeetingListItem | SearchHit): string | null {
return "snippet" in item ? item.snippet : null;
}
function formatDate(unixSecs: number): string {
return new Date(unixSecs * 1000).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function formatDuration(secs: number | null): string {
if (!secs) return "";
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
async function openMeeting(m: MeetingListItem | SearchHit) {
if (recording.state !== "idle" && recording.meetingId === m.id) return; // still live
await meetings.select(m.id);
}
async function removeMeeting(e: Event, id: string) {
e.stopPropagation();
if (!confirm(t("meetings.delete_confirm"))) return;
await meetings.remove(id);
}
async function resumeMeeting(e: Event, id: string) {
e.stopPropagation();
await meetings.resume(id);
}
</script>
<div class="wrap">
<div class="search-box">
<Search size={15} aria-hidden="true" />
<input
placeholder={t("meetings.search_placeholder")}
aria-label={t("meetings.search_aria")}
bind:value={query}
oninput={onSearchInput}
/>
</div>
<div class="filters">
<select
aria-label={t("meetings.filter_tag_aria")}
bind:value={tagFilter}
onchange={onFilterChange}
>
<option value="">{t("meetings.all_tags")}</option>
{#each meetings.allTags as tag (tag)}
<option value={tag}>{tag}</option>
{/each}
</select>
<input
type="date"
aria-label={t("meetings.from_date_aria")}
bind:value={fromFilter}
onchange={onFilterChange}
/>
<input
type="date"
aria-label={t("meetings.to_date_aria")}
bind:value={toFilter}
onchange={onFilterChange}
/>
</div>
<div class="filters">
<select aria-label={t("meetings.bulk_format_aria")} bind:value={bulkFormat}>
<option value="md">.md</option>
<option value="pdf">.pdf</option>
<option value="docx">.docx</option>
<option value="bundle">bundle</option>
</select>
<button
class="bulk-btn"
onclick={bulkExport}
disabled={bulkExporting}
title={t("meetings.bulk_export_title")}
>
<Download size={13} aria-hidden="true" />
{bulkExporting ? t("meetings.exporting") : t("meetings.bulk_export")}
</button>
</div>
{#if bulkResult}
<p class="muted small">{bulkResult}</p>
{/if}
{#if meetings.searching}
<p class="muted">{t("meetings.searching")}</p>
{:else if displayItems.length === 0 && meetings.loading}
<p class="muted">{t("meetings.loading")}</p>
{:else if displayItems.length === 0 && meetings.searchResults !== null}
<p class="muted">{t("meetings.no_matches")}</p>
{:else if displayItems.length === 0}
<p class="muted">{t("meetings.empty")}</p>
{:else}
<ul>
{#each displayItems as m (m.id)}
<li class:active={meetings.selectedId === m.id}>
<button class="item-btn" onclick={() => openMeeting(m)}>
<span class="row">
<span class="title">{m.title}</span>
{#if m.status === "recording" || m.status === "transcribing"}
<span class="badge live">{statusLabel(m.status)}</span>
{:else if m.status === "recovering"}
<span class="badge recovering">{statusLabel("recovering")}</span>
{:else if m.status === "error"}
<span class="badge error">{statusLabel("error")}</span>
{/if}
</span>
<span class="row muted small">
<span>{formatDate(m.started_at)}</span>
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
</span>
{#if snippetOf(m)}
<span class="snippet">{snippetOf(m)}</span>
{/if}
{#if m.tags.length > 0}
<span class="row tags">
{#each m.tags as tag (tag)}
<span class="chip">{tag}</span>
{/each}
</span>
{/if}
</button>
<div class="row actions">
{#if m.status === "recovering"}
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}>
<RotateCcw size={12} aria-hidden="true" />
{t("meetings.resume")}
</button>
{/if}
<button
class="link danger"
onclick={(e) => removeMeeting(e, m.id)}
aria-label={t("meetings.delete_aria")}
title={t("meetings.delete_aria")}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.wrap {
padding: 0.75rem;
}
.search-box {
position: relative;
margin-bottom: 0.5rem;
}
.search-box :global(svg) {
position: absolute;
left: 0.55rem;
top: 50%;
transform: translateY(-50%);
color: var(--fg-subtle);
pointer-events: none;
}
.search-box input {
padding-left: 1.9rem;
margin-bottom: 0;
}
input {
width: 100%;
padding: 0.45rem 0.6rem;
margin-bottom: 0.5rem;
box-sizing: border-box;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
input:focus-visible {
border-color: var(--accent);
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.small {
font-size: 0.75rem;
}
.snippet {
display: block;
color: var(--muted);
font-size: 0.78rem;
margin-top: 0.15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filters {
display: flex;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.filters select,
.filters input,
.filters button {
flex: 1;
min-width: 0;
padding: 0.3rem;
font-size: 0.78rem;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.bulk-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
cursor: pointer;
}
.bulk-btn:hover {
background: var(--bg-hover);
}
.tags {
justify-content: flex-start;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.chip {
font-size: 0.68rem;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-full);
background: var(--accent-soft);
color: var(--accent);
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0.55rem 0.7rem 0.55rem 0.6rem;
border-bottom: 1px solid var(--border);
border-left: 2px solid transparent;
transition: background-color 120ms ease-out;
}
li:hover {
background: var(--bg-hover);
}
li.active {
background: var(--accent-soft);
border-left-color: var(--accent);
}
.item-btn {
display: block;
width: 100%;
background: none;
border: none;
color: inherit;
font: inherit;
text-align: left;
padding: 0;
cursor: pointer;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-full);
border: 1px solid var(--border);
}
.badge.live {
color: var(--danger);
border-color: var(--danger);
}
.badge.recovering {
color: var(--warning);
border-color: var(--warning);
}
.badge.error {
color: var(--muted);
border-color: var(--muted);
}
.actions {
margin-top: 0.3rem;
justify-content: flex-end;
gap: 0.6rem;
}
.link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 0.75rem;
padding: 0;
}
.link.danger:hover {
color: var(--danger);
}
</style>