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
@@ -2,81 +2,141 @@
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import { formatListDate } from "#lib/format/datetime.js";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||
import { mediaKindLabel } from "#lib/format/media.js";
|
||||
import { peerName } from "#lib/format/peer.js";
|
||||
import { highlight } from "#lib/format/search.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { peers } from "#lib/stores/peers.svelte.js";
|
||||
|
||||
interface Props {
|
||||
hit: SearchHit;
|
||||
href: string;
|
||||
inChat?: boolean;
|
||||
onclick: () => void;
|
||||
terms: string[];
|
||||
}
|
||||
|
||||
let { hit, onclick }: Props = $props();
|
||||
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
||||
|
||||
const chat = $derived(chats.byId(hit.chat_id));
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
|
||||
|
||||
$effect(() => {
|
||||
chats.ensure(hit.chat_id);
|
||||
const ids: number[] = [];
|
||||
if (hit.chat_id > 0) {
|
||||
ids.push(hit.chat_id);
|
||||
}
|
||||
if (hit.sender_id !== null) {
|
||||
ids.push(hit.sender_id);
|
||||
}
|
||||
const ids = [hit.chat_id, hit.sender_id].filter(
|
||||
(id): id is number => id !== null && id > 0
|
||||
);
|
||||
if (ids.length > 0) {
|
||||
peers.ensure(ids);
|
||||
}
|
||||
});
|
||||
|
||||
const title = $derived.by(() => {
|
||||
const chatTitle = $derived.by(() => {
|
||||
if (chat?.title) {
|
||||
return chat.title;
|
||||
}
|
||||
if (hit.chat_id > 0) {
|
||||
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`;
|
||||
}
|
||||
return `Chat ${hit.chat_id}`;
|
||||
const peer = hit.chat_id > 0 ? peers.get(hit.chat_id) : undefined;
|
||||
return (
|
||||
(peer && peerName(peer)) || t("search.chatTitle", { id: hit.chat_id })
|
||||
);
|
||||
});
|
||||
|
||||
const sender = $derived.by(() => {
|
||||
const senderName = $derived.by(() => {
|
||||
if (hit.sender_id === null) {
|
||||
return "";
|
||||
}
|
||||
if (hit.sender_id === ownId) {
|
||||
return "Вы";
|
||||
if (own) {
|
||||
return t("search.you");
|
||||
}
|
||||
return peerName(peers.get(hit.sender_id) ?? null);
|
||||
});
|
||||
|
||||
const snippet = $derived(hit.text ?? hit.extracted_text ?? "");
|
||||
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
|
||||
const avatarId = $derived(
|
||||
inChat ? (hit.sender_id ?? hit.chat_id) : hit.chat_id
|
||||
);
|
||||
const title = $derived(inChat ? senderName || chatTitle : chatTitle);
|
||||
const prefix = $derived(
|
||||
inChat || !senderName || hit.chat_id === hit.sender_id ? "" : senderName
|
||||
);
|
||||
const avatarHas = $derived(
|
||||
avatarId === hit.chat_id
|
||||
? (chat?.has_avatar ?? false)
|
||||
: (peers.get(avatarId)?.has_avatar ?? false)
|
||||
);
|
||||
|
||||
const body = $derived.by(() => {
|
||||
if (hit.source === "version") {
|
||||
return hit.version_text ?? "";
|
||||
}
|
||||
if (hit.source === "stt") {
|
||||
return hit.extracted_text ?? "";
|
||||
}
|
||||
return hit.text ?? "";
|
||||
});
|
||||
const kind = $derived(
|
||||
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
||||
);
|
||||
const segments = $derived(highlight(body, terms));
|
||||
</script>
|
||||
|
||||
<button class="hit" {onclick} type="button" use:ripple>
|
||||
<a class="hit" {href} {onclick} use:ripple>
|
||||
<Avatar
|
||||
avatar={{ id: hit.chat_id, kind: avatarKind }}
|
||||
colorKey={hit.chat_id}
|
||||
hasAvatar={chat?.has_avatar ?? false}
|
||||
avatar={{ id: avatarId, kind: avatarId > 0 ? "peer" : "chat" }}
|
||||
colorKey={avatarId}
|
||||
hasAvatar={avatarHas}
|
||||
name={title}
|
||||
size={3}
|
||||
size={inChat ? 2.5 : 3}
|
||||
/>
|
||||
<div class="body">
|
||||
<div class="top">
|
||||
<span class="title">{title}</span>
|
||||
<span class="date">{formatListDate(hit.date)}</span>
|
||||
{#if hit.deleted_at}
|
||||
<span class="mark deleted" title={formatFull(hit.deleted_at)}>
|
||||
<Icon name="delete" size="0.875rem" />
|
||||
{t("search.markDeleted")}
|
||||
</span>
|
||||
{:else if hit.edited_at}
|
||||
<span class="mark" title={formatFull(hit.edited_at)}>
|
||||
{t("search.markEdited")}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="date" title={formatFull(hit.date)}>
|
||||
{formatListDate(hit.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="snippet">
|
||||
{#if sender}
|
||||
<span class="sender">{sender}: </span>
|
||||
{#if prefix}
|
||||
<span class="sender">{prefix}:</span>
|
||||
{/if}
|
||||
{snippet}
|
||||
{#if kind}
|
||||
<span class="kind">{kind}{body ? ":" : ""}</span>
|
||||
{/if}
|
||||
<span
|
||||
>{#each segments as segment, index (index)}
|
||||
{#if segment.hit}
|
||||
<mark>{segment.text}</mark>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}</span
|
||||
>
|
||||
</div>
|
||||
{#if hit.source === "version"}
|
||||
<div
|
||||
class="note"
|
||||
title={hit.version_at ? formatFull(hit.version_at) : undefined}
|
||||
>
|
||||
<Icon name="edit" size="0.875rem" />
|
||||
{t("search.version")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
.hit {
|
||||
@@ -89,18 +149,25 @@
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,24 +195,59 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.1875rem;
|
||||
align-self: center;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.deleted {
|
||||
font-style: normal;
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.snippet {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender {
|
||||
.sender,
|
||||
.kind {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0 0.0625rem;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-text);
|
||||
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user