atagen

malice

public
Star0
Watch0
48af827771081b0e3b68719cf046485aaab65304
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/SearchView.svelteSvelte140 lines6.2 KB
1
<script lang="ts">
2
  import { onDestroy, onMount } from 'svelte';
3
  import EmojiText from './EmojiText.svelte';
4
  import MessageRow from './MessageRow.svelte';
5
  import { closeSearch, searchMessages, searchMore } from './api';
6
  import { formatDay } from './format';
7
  import { groupByRoom } from './search-model';
8
  import type { ScopeOption, SearchCoverage, SearchHit, SearchPage } from './search-model';
9
10
  export let scopes: ScopeOption[];
11
  export let initialScope = 0;
12
  export let self: string | null = null;
13
  export let roomName: (roomId: string) => string;
14
  export let onOpen: (roomId: string, eventId: string, threadRoot: string | null) => void;
15
16
  let input: HTMLInputElement | null = null;
17
  let query = '';
18
  let scopeIndex = Math.min(initialScope, scopes.length - 1);
19
  let hits: SearchHit[] = [];
20
  let coverage: SearchCoverage | null = null;
21
  let next: string | null = null;
22
  let searchId: string | null = null;
23
  let searched = '';
24
  let busy = false;
25
  let error = '';
26
  let generation = 0;
27
28
  $: groups = groupByRoom(hits);
29
  $: partial = coverage?.rooms.filter((c) => !c.start_reached) ?? [];
30
  $: oldest = partial.reduce<number | null>((min, c) => (c.oldest_indexed_ms === null ? min : min === null ? c.oldest_indexed_ms : Math.max(min, c.oldest_indexed_ms)), null);
31
32
  onMount(() => input?.focus());
33
  onDestroy(() => dropSearch());
34
35
  function dropSearch(): void {
36
    if (searchId) void closeSearch(searchId).catch(() => undefined);
37
    searchId = null;
38
  }
39
40
  async function run(more = false): Promise<void> {
41
    const text = query.trim();
42
    if (!text) return;
43
    const run = ++generation;
44
    busy = true;
45
    error = '';
46
    try {
47
      let page: SearchPage;
48
      if (more && searchId && next) {
49
        page = await searchMore(searchId, next);
50
      } else {
51
        const session = await searchMessages(text, scopes[scopeIndex].scope);
52
        if (run !== generation) {
53
          void closeSearch(session.search_id).catch(() => undefined);
54
          return;
55
        }
56
        dropSearch();
57
        searchId = session.search_id;
58
        page = session.page;
59
      }
60
      if (run !== generation) return;
61
      hits = more ? [...hits, ...page.hits] : page.hits;
62
      coverage = page.coverage;
63
      next = page.next;
64
      searched = text;
65
    } catch (e) {
66
      if (run !== generation) return;
67
      if (!more) hits = [];
68
      error = e instanceof Error ? e.message : 'Search failed';
69
    } finally {
70
      if (run === generation) busy = false;
71
    }
72
  }
73
74
  function pickScope(index: number): void {
75
    scopeIndex = index;
76
    if (searched) void run();
77
  }
78
</script>
79
80
<section class="search-view" aria-label="Search messages">
81
  <form class="search-bar" on:submit|preventDefault={() => void run()}>
82
    <input bind:this={input} type="search" bind:value={query} placeholder="Search messages" aria-label="Search messages" />
83
    <button type="submit" class="primary-btn" disabled={busy || !query.trim()}>{busy && !hits.length ? 'Searching…' : 'Search'}</button>
84
  </form>
85
  <div class="scopes" role="radiogroup" aria-label="Search in">
86
    {#each scopes as option, i (option.label)}
87
      <button type="button" role="radio" aria-checked={i === scopeIndex} class:active={i === scopeIndex} on:click={() => pickScope(i)}>
88
        <EmojiText text={option.label} />
89
      </button>
90
    {/each}
91
  </div>
92
  {#if error}
93
    <p class="search-error" role="alert"><EmojiText text={error} /></p>
94
  {/if}
95
  <div class="results">
96
    {#if searched && !busy && !error && hits.length === 0}
97
      <p class="side-note">No messages match “<EmojiText text={searched} />”</p>
98
    {/if}
99
    {#each groups as group (group.room_id)}
100
      <h3 class="result-room"><EmojiText text={roomName(group.room_id)} /></h3>
101
      {#each group.hits as hit (hit.event_id)}
102
        <div
103
          class="result"
104
          role="link"
105
          tabindex="0"
106
          aria-label={`Open message in ${roomName(hit.room_id)}`}
107
          on:click={() => onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
108
          on:keydown={(e) => e.key === 'Enter' && onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
109
        >
110
          <MessageRow itemId={`search-${hit.event_id}`} event={hit.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} />
111
        </div>
112
      {/each}
113
    {/each}
114
    {#if next}
115
      <button type="button" class="ghost-btn more" disabled={busy} on:click={() => void run(true)}>{busy ? 'Loading…' : 'More results'}</button>
116
    {/if}
117
    {#if searched && partial.length}
118
      <p class="coverage">Searched back to {oldest ? formatDay(oldest) : 'recent history'} in {partial.length === 1 ? 'one room' : `${partial.length} rooms`}</p>
119
    {/if}
120
  </div>
121
</section>
122
123
<style>
124
  .search-view { display: flex; flex-direction: column; min-height: 0; height: 100%; }
125
  .search-bar { display: flex; gap: 8px; padding: 12px 16px 8px; }
126
  .search-bar input { flex: 1; min-width: 0; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); padding: 8px 10px; font: inherit; }
127
  .search-bar input:focus { outline: none; border-color: var(--accent); }
128
  .scopes { display: flex; gap: 6px; padding: 0 16px 10px; border-bottom: 1px solid var(--border); }
129
  .scopes button { text-align: left; border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: var(--r-sm); padding: 3px 10px; font: inherit; font-size: 12.5px; cursor: pointer; transition: color var(--pop), border-color var(--pop), background var(--pop); }
130
  .scopes button:hover { color: var(--text); }
131
  .scopes button.active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
132
  .search-error { margin: 10px 16px 0; color: var(--danger); font-size: 13px; }
133
  .results { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 16px 16px; }
134
  .result-room { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
135
  .result { border-radius: var(--r-md); cursor: pointer; }
136
  .result:hover { background: var(--hover-faint); }
137
  .result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
138
  .more { display: block; margin: 12px auto 0; }
139
  .coverage { margin: 14px 0 0; text-align: center; font-size: 12px; color: var(--muted); }
140
</style>