feat(ui): replace title-only filter with full-text search (T8.2, FR-SEARCH-1)
The search box now searches transcripts + notes (not just titles) and shows a matched snippet per result. FTS5 is word-based rather than substring-based, a deliberate tradeoff for gaining full-content search.
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
<script lang="ts">
|
||||
// Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search).
|
||||
// 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 type { MeetingListItem } from "../api";
|
||||
import type { MeetingListItem, SearchHit } from "../api";
|
||||
|
||||
let query = $state("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => meetings.load(query || undefined), 250);
|
||||
searchTimer = setTimeout(() => meetings.search(query), 250);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -28,7 +37,7 @@
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
async function openMeeting(m: MeetingListItem) {
|
||||
async function openMeeting(m: MeetingListItem | SearchHit) {
|
||||
if (recording.state !== "idle" && recording.meetingId === m.id) return; // still live
|
||||
await meetings.select(m.id);
|
||||
}
|
||||
@@ -52,13 +61,17 @@
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
{#if meetings.list.length === 0 && meetings.loading}
|
||||
{#if meetings.searching}
|
||||
<p class="muted">Searching…</p>
|
||||
{:else if displayItems.length === 0 && meetings.loading}
|
||||
<p class="muted">Loading meetings…</p>
|
||||
{:else if meetings.list.length === 0}
|
||||
{:else if displayItems.length === 0 && meetings.searchResults !== null}
|
||||
<p class="muted">No matches.</p>
|
||||
{:else if displayItems.length === 0}
|
||||
<p class="muted">No meetings yet. Click ● Record to start.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each meetings.list as m (m.id)}
|
||||
{#each displayItems as m (m.id)}
|
||||
<li class:active={meetings.selectedId === m.id}>
|
||||
<button class="item-btn" onclick={() => openMeeting(m)}>
|
||||
<span class="row">
|
||||
@@ -75,6 +88,9 @@
|
||||
<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}
|
||||
</button>
|
||||
<div class="row actions">
|
||||
{#if m.status === "recovering"}
|
||||
@@ -113,6 +129,15 @@
|
||||
.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;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user