feat(backend,frontend): search everywhere in buttons, links and message data, a jump from search or an alert lights up the found words and buttons
This commit is contained in:
1 parent
acfa726e61
commit
d660079c02
21 files changed
+840
-76
No files matched your search
@@ -0,0 +1,208 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user