397 lines
11 KiB
Svelte
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>
|