Files
beavergram/frontend/src/lib/components/search/SearchEverywhere.svelte
T

209 lines
4.5 KiB
Svelte

<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import { visible } from "#lib/actions/visible.js";
import type { SearchHit } from "#lib/api/types.js";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import {
EVERYWHERE_MIN,
type HitPager,
hitKey,
} from "#lib/stores/search.svelte.js";
interface Props {
href: (hit: SearchHit) => string;
inChat?: boolean;
needle: string;
onopen?: (hit: SearchHit) => void;
onstart: () => void;
pager: HitPager;
ready: boolean;
shown: SearchHit[];
started: boolean;
}
let {
href,
inChat = false,
needle,
onopen,
onstart,
pager,
ready,
shown,
started,
}: Props = $props();
const hits = $derived.by(() => {
const seen = new Set(shown.map(hitKey));
return pager.hits.filter((hit) => {
const key = hitKey(hit);
const fresh = !seen.has(key);
seen.add(key);
return fresh;
});
});
const busy = $derived(pager.loading || pager.appending);
function retry() {
if (pager.hasMore) {
pager.more();
} else {
pager.retry();
}
}
</script>
{#if !started}
{#if ready && needle.length >= EVERYWHERE_MIN}
<button
class="start"
onclick={onstart}
type="button"
class:in-chat={inChat}
use:ripple
>
<span class="glyph"><Icon name="search" size="1.5rem" /></span>
<span class="body">
<span class="label">{t("search.everywhere")}</span>
<span class="hint">{t("search.everywhereHint")}</span>
</span>
</button>
{/if}
{:else}
{#if hits.length > 0}
<div class="section-label">{t("search.everywhereSection")}</div>
{#each hits as hit (hitKey(hit))}
<SearchMessageItem
{hit}
href={href(hit)}
{inChat}
onclick={() => onopen?.(hit)}
terms={[needle]}
/>
{/each}
{/if}
{#if busy}
<div class="status">
<Spinner size="1.25rem" />
<span>
{pager.scannedTo
? t("search.everywhereScanned", { date: formatDate(pager.scannedTo) })
: t("search.everywhereScanning")}
</span>
</div>
{:else if pager.failed}
<div class="status failed">
<span>{t("search.failed")}</span>
<Button onclick={retry} variant="text">{t("search.retry")}</Button>
</div>
{:else if pager.hasMore}
<div class="sentinel" use:visible={() => pager.more()}></div>
{:else if hits.length === 0}
<div class="status">{t("search.everywhereNothing")}</div>
{/if}
{/if}
<style lang="scss">
.start {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
color: var(--color-primary);
.in-chat & {
width: 2.5rem;
height: 2.5rem;
}
}
.body {
display: flex;
flex-direction: column;
gap: 0.1875rem;
min-width: 0;
}
.label {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.hint {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.section-label {
padding: 0.625rem 0.75rem 0.375rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 0.75rem 1.5rem;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
&.failed {
flex-direction: column;
}
}
.sentinel {
height: 1px;
}
</style>