feat(frontend): search results show deleted, edited and earlier version marks with highlighted words, filter chips live in the url, results load on scroll

This commit is contained in:
hh committed 2026-10-19 03:41:44 +00:00
1 parent d45c96c259
commit 9a6bd8a714
15 files changed
+1430 -228

No files matched your search

@@ -1,69 +1,58 @@
<script lang="ts">
import { searchMessages } from "#lib/api/endpoints.js";
import type { SearchHit } from "#lib/api/types.js";
import { untrack } from "svelte";
import { visible } from "#lib/actions/visible.js";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { createHitPager, search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { isMobile } from "#lib/viewport.js";
import { page } from "$app/state";
const DEBOUNCE_MS = 250;
const pager = createHitPager();
let query = $state("");
let typing = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
let query = $state("");
let hits = $state<SearchHit[]>([]);
let loading = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
async function run(value: string, id: number) {
seq += 1;
const current = seq;
loading = true;
try {
const result = await searchMessages(value, { chat_id: id });
if (current === seq) {
hits = result;
}
} catch {
if (current === seq) {
hits = [];
}
} finally {
if (current === seq) {
loading = false;
}
}
function run() {
typing = false;
const value = query.trim();
pager.run(
value && chatId !== null ? { chat_id: chatId, query: value } : null
);
}
$effect(() => {
if (chatId !== null) {
untrack(run);
}
});
function onInput(event: Event) {
query = (event.currentTarget as HTMLInputElement).value;
const value = query.trim();
if (timer) {
typing = true;
if (timer !== null) {
clearTimeout(timer);
}
if (value.length === 0 || chatId === null) {
seq += 1;
hits = [];
loading = false;
return;
}
const id = chatId;
loading = true;
timer = setTimeout(() => {
run(value, id).catch(() => undefined);
}, DEBOUNCE_MS);
timer = setTimeout(run, DEBOUNCE_MS);
}
function openHit(messageId: number) {
if (chatId !== null) {
ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`);
if (chatId === null) {
return;
}
ui.requestJump(chatId, messageId);
if (isMobile()) {
ui.closePanel();
}
}
</script>
@@ -72,21 +61,43 @@
<div class="search-input">
<Icon class="chat-search-icon" name="search" size="1.25rem" />
<input
aria-label={t("search.inChat")}
oninput={onInput}
placeholder="Поиск в чате"
type="text"
placeholder={t("search.inChat")}
type="search"
value={query}
>
</div>
<div class="results custom-scroll">
{#if loading && hits.length === 0}
<div class="loading"><Spinner /></div>
{:else if hits.length > 0}
{#each hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem {hit} onclick={() => openHit(hit.message_id)} />
{#if pager.hits.length > 0}
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem
{hit}
href={search.hitHref(hit.chat_id, hit.message_id)}
inChat
onclick={() => openHit(hit.message_id)}
terms={pager.terms}
/>
{/each}
{:else if query.trim()}
<EmptyState title="Ничего не найдено" />
{#if pager.hasMore}
{#key pager.hits.length}
<div class="sentinel" use:visible={() => pager.more()}></div>
{/key}
{/if}
{#if pager.appending}
<div class="loading"><Spinner /></div>
{/if}
{:else if pager.loading}
<div class="loading"><Spinner /></div>
{:else if pager.failed}
<div class="failed">
<p>{t("search.failed")}</p>
<Button onclick={() => pager.retry()} variant="text"
>{t("search.retry")}</Button
>
</div>
{:else if query.trim() && !typing}
<EmptyState title={t("search.nothing")} />
{/if}
</div>
</div>
@@ -135,6 +146,10 @@
color: var(--color-text-secondary);
}
&::-webkit-search-cancel-button {
display: none;
}
&:focus {
outline: none;
}
@@ -146,9 +161,27 @@
padding: 0.25rem 0.4375rem;
}
.sentinel {
height: 1px;
}
.loading {
display: flex;
justify-content: center;
padding: 2rem 0;
padding: 1.5rem 0;
}
.failed {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
p {
margin: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
}
</style>