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:
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>
|
||||
Reference in new issue
Block a user