feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log

This commit is contained in:
hh committed 2026-10-19 02:57:27 +00:00
1 parent 6a4fd6ad47
commit ea3a7c0a61
19 files changed
+3699 -458

No files matched your search

@@ -1,82 +0,0 @@
<script lang="ts">
import type { Alert } from "#lib/api/types.js";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
alerts: Alert[];
onseen: (alert: Alert) => void;
}
const { alerts, onseen }: Props = $props();
function text(alert: Alert): string {
const p = alert.payload;
const value = p.text ?? p.message ?? p.keyword;
return typeof value === "string" ? value : JSON.stringify(p);
}
</script>
<ul class="alert-list">
{#each alerts as alert (alert.id)}
<li class="alert-item" class:unseen={!alert.seen}>
<div class="alert-main">
<p class="alert-text">{text(alert)}</p>
<time class="alert-date">{new Date(alert.ts).toLocaleString()}</time>
</div>
{#if !alert.seen}
<Button
aria-label="Отметить прочитанным"
onclick={() => onseen(alert)}
round
smaller
variant="translucent"
>
<Icon name="check" />
</Button>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.alert-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.alert-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.unseen {
border-color: var(--color-primary);
}
}
.alert-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.alert-text {
margin: 0;
overflow-wrap: anywhere;
}
.alert-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,72 +1,640 @@
<script lang="ts">
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js";
import type { Alert } from "#lib/api/types.js";
import { onMount, untrack } from "svelte";
import {
type AlertItem,
listAlerts,
markAlertSeen,
markAllAlertsSeen,
} from "#lib/api/watches.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.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 { formatFull, formatListDate } from "#lib/format/datetime.js";
import {
alertAvatar,
alertLabel,
alertPreview,
alertTitle,
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import Spinner from "../ui/Spinner.svelte";
import AlertList from "./AlertList.svelte";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let alerts = $state<Alert[]>([]);
type Filter = "all" | "unseen";
const PAGE = 50;
let items = $state<AlertItem[]>([]);
let nextBefore = $state<number | null>(null);
let loading = $state(true);
let unseenOnly = $state(false);
let loadingMore = $state(false);
let failed = $state(false);
let notifyProblem = $state(false);
let token = 0;
async function load(_account: number | null, unseen: boolean) {
loading = true;
let filter = $state<Filter>(
untrack(() => page.url.searchParams.get(ALERTS_PARAM)) === "unseen"
? "unseen"
: "all"
);
function writeParam(value: Filter | null) {
const url = new URL(page.url.href);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
}
function setFilter(next: Filter) {
filter = next;
writeParam(next);
}
async function load(current: Filter, before: number | null = null) {
token += 1;
const mine = token;
if (before === null) {
loading = true;
} else {
loadingMore = true;
}
try {
alerts = await listAlerts(unseen ? { seen: false } : {});
const result = await listAlerts({
before: before ?? undefined,
limit: PAGE,
seen: current === "unseen" ? false : undefined,
});
if (mine !== token) {
return;
}
items = before === null ? result.items : [...items, ...result.items];
nextBefore = result.next_before;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally {
loading = false;
if (mine === token) {
loading = false;
loadingMore = false;
}
}
}
$effect(() => {
load(accounts.selectedId, unseenOnly);
if (accounts.selectedId !== null) {
load(filter);
}
});
async function seen(alert: Alert) {
await markAlertSeen(alert.id);
if (unseenOnly) {
alerts = alerts.filter((a) => a.id !== alert.id);
} else {
alerts = alerts.map((a) =>
a.id === alert.id ? { ...a, seen: true } : a
);
onMount(() => {
writeParam(filter);
const stop = alerts.onAlert((alert) => {
if (!items.some((item) => item.id === alert.id)) {
items = [alert, ...items];
}
});
return () => {
stop();
writeParam(null);
};
});
function markLocal(alert: AlertItem) {
if (alert.seen) {
return;
}
items = items.map((item) =>
item.id === alert.id ? { ...item, seen: true } : item
);
alerts.read();
}
async function seen(alert: AlertItem) {
if (alert.seen) {
return;
}
markLocal(alert);
await markAlertSeen(alert.id).catch(() => undefined);
}
async function openAlert(alert: AlertItem) {
seen(alert);
await goto(alert.path);
if (isMobile()) {
ui.closePanel();
}
}
async function seeAll() {
items = items.map((item) => ({ ...item, seen: true }));
alerts.readAll();
await markAllAlertsSeen().catch(() => undefined);
}
async function toggleNotify() {
notifyProblem = false;
const wanted = !alerts.notify;
const done = await alerts.setNotify(wanted);
notifyProblem = wanted && !done;
}
</script>
<div class="panel-content">
<label class="toggle">
<input type="checkbox" bind:checked={unseenOnly}>
<span>Только непрочитанные</span>
</label>
{#if loading}
<Spinner />
{:else if alerts.length === 0}
<p class="empty">Алертов нет.</p>
{:else}
<AlertList {alerts} onseen={seen} />
{/if}
<div class="tabs">
<button
aria-pressed={filter === "all"}
class="tab"
onclick={() => setFilter("all")}
type="button"
class:on={filter === "all"}
>
{t("alerts.all")}
</button>
<button
aria-pressed={filter === "unseen"}
class="tab"
onclick={() => setFilter("unseen")}
type="button"
class:on={filter === "unseen"}
>
{t("alerts.unseen")}
</button>
</div>
{#if alerts.unseen > 0}
<button class="act" onclick={seeAll} type="button">
<Icon name="readchats" size="1.375rem" />
<span class="label">{t("alerts.markAll")}</span>
<span class="value">{alerts.unseen}</span>
</button>
{/if}
{#if alerts.notifySupported}
<button
aria-checked={alerts.notify}
class="toggle"
onclick={toggleNotify}
role="switch"
type="button"
>
<Icon name="unmute" size="1.375rem" />
<span class="label">{t("alerts.notify")}</span>
<span class="switch" class:on={alerts.notify}
><span class="knob"></span></span
>
</button>
{#if notifyProblem}
<p class="problem">{t("alerts.notifyDenied")}</p>
{/if}
{/if}
{#if loading && items.length === 0}
<div class="state"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("alerts.loadFailed")}</p>
<Button onclick={() => load(filter)} smaller variant="text">
{t("alerts.retry")}
</Button>
</div>
{:else if items.length === 0}
<div class="empty">
<EmptyState
description={filter === "unseen"
? undefined
: t("alerts.empty.description")}
title={filter === "unseen"
? t("alerts.emptyUnseen.title")
: t("alerts.empty.title")}
/>
{#if filter === "all"}
<Button onclick={() => ui.openPanel("watches")} smaller variant="text">
{t("alerts.setup")}
</Button>
{/if}
</div>
{:else}
<ul class="list">
{#each items as alert (alert.id)}
{@const avatar = alertAvatar(alert)}
{@const preview = alertPreview(alert)}
<li>
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
class="alert"
onclick={() => openAlert(alert)}
type="button"
class:unseen={!alert.seen}
>
<span class="lead">
<Avatar
avatar={{ id: avatar.id, kind: avatar.kind }}
colorKey={avatar.id}
hasAvatar={avatar.hasAvatar}
name={avatar.name}
size={3}
/>
<span class="kind {alert.event}">
<Icon name={EVENT_ICONS[alert.event]} size="0.75rem" />
</span>
</span>
<span class="text">
<span class="top">
<span class="title">{alertTitle(alert)}</span>
<time datetime={alert.ts} title={formatFull(alert.ts)}>
{formatListDate(alert.ts)}
</time>
</span>
<span class="bottom">
<span class="line">
<span class="what" class:alone={!preview}>
{alertLabel(alert)}
</span>
{preview}
</span>
{#if !alert.seen}
<span class="dot"></span>
{/if}
</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem
icon={alert.message ? "reply" : "message"}
onselect={() => openAlert(alert)}
>
{alert.message ? t("alerts.open") : t("alerts.openChat")}
</ContextMenuItem>
{#if !alert.seen}
<ContextMenuItem icon="check" onselect={() => seen(alert)}>
{t("alerts.markSeen")}
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<button
class="more"
disabled={loadingMore}
onclick={() => load(filter, nextBefore)}
type="button"
>
{t("alerts.more")}
</button>
{/if}
{/if}
<style lang="scss">
.panel-content {
.tabs {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
}
.tab {
cursor: pointer;
flex: 1;
height: 2.75rem;
border: 0;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
transition: color 0.15s ease-out;
&.on {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.on):hover {
color: var(--color-text);
}
}
}
.act,
.toggle {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
.label {
flex: 1;
}
.value {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid currentcolor;
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.toggle {
color: var(--color-text);
:global(.icon) {
color: var(--color-icon-secondary);
}
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.problem {
margin: 0;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-borders);
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.empty {
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2.5rem 1rem 2rem;
text-align: center;
}
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.alert {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.lead {
position: relative;
flex-shrink: 0;
}
.kind {
position: absolute;
right: -0.125rem;
bottom: -0.125rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-background);
&.delete {
background-color: var(--color-error);
}
&.online {
background-color: var(--color-green);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.top,
.bottom {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.title {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
.unseen & {
font-weight: var(--font-weight-medium);
}
}
time {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.line {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.what {
color: var(--color-text);
&:not(.alone)::after {
content: ":";
}
}
.dot {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-primary);
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0 0.5rem 1rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
@media (pointer: coarse) {
min-height: 2.75rem;
}
}
</style>