feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log
This commit is contained in:
1 parent
6a4fd6ad47
commit
ea3a7c0a61
19 files changed
+3699
-458
No files matched your search
@@ -19,6 +19,7 @@
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
@@ -27,7 +28,7 @@
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const titles: Record<RightPanel, string> = $derived({
|
||||
alerts: "Алерты",
|
||||
alerts: t("settings.alerts"),
|
||||
annotations: "Заметки",
|
||||
bots: "Боты",
|
||||
callbacks: "Callback-данные",
|
||||
@@ -46,12 +47,14 @@
|
||||
stories: "Сторис",
|
||||
"stories-all": "Все сторис",
|
||||
versions: "Версии",
|
||||
watches: "Отслеживания",
|
||||
watches: t("settings.watches"),
|
||||
webhooks: t("watches.webhooks"),
|
||||
});
|
||||
|
||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||
"storage-chat": "storage",
|
||||
"storage-trash": "storage",
|
||||
webhooks: "watches",
|
||||
};
|
||||
|
||||
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
||||
@@ -118,7 +121,13 @@
|
||||
{:else if ui.rightPanel === "stories-all"}
|
||||
<AllStoriesArchive />
|
||||
{:else if ui.rightPanel === "watches"}
|
||||
<WatchesPanel />
|
||||
{#key accounts.selectedId}
|
||||
<WatchesPanel />
|
||||
{/key}
|
||||
{:else if ui.rightPanel === "webhooks"}
|
||||
{#key accounts.selectedId}
|
||||
<WebhooksPanel />
|
||||
{/key}
|
||||
{:else if ui.rightPanel === "alerts"}
|
||||
<AlertsPanel />
|
||||
{:else if ui.rightPanel === "annotations"}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import type { Chat } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import type { Picked } from "#lib/format/watches.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||
|
||||
interface Props {
|
||||
empty: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
onpick: (value: Picked | null) => void;
|
||||
people?: boolean;
|
||||
required?: boolean;
|
||||
value: Picked | null;
|
||||
}
|
||||
|
||||
const {
|
||||
empty,
|
||||
icon,
|
||||
label,
|
||||
onpick,
|
||||
people = false,
|
||||
required = false,
|
||||
value,
|
||||
}: Props = $props();
|
||||
|
||||
const SHOWN = 8;
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state("");
|
||||
|
||||
const picker = createChatPicker();
|
||||
const results = $derived(
|
||||
picker.results
|
||||
.filter((chat) => !people || (chat.chat_id > 0 && !chat.is_bot))
|
||||
.slice(0, SHOWN)
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
if (open) {
|
||||
picker.search(query);
|
||||
}
|
||||
}
|
||||
|
||||
function choose(chat: Chat) {
|
||||
onpick({
|
||||
hasAvatar: chat.has_avatar,
|
||||
id: chat.chat_id,
|
||||
title: chat.title ?? t("watches.unknownChat", { id: chat.chat_id }),
|
||||
});
|
||||
open = false;
|
||||
query = "";
|
||||
}
|
||||
|
||||
function focus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field" class:open>
|
||||
<div class="line">
|
||||
<button aria-expanded={open} class="main" onclick={toggle} type="button">
|
||||
{#if value}
|
||||
<Avatar
|
||||
avatar={{ id: value.id, kind: value.id > 0 ? "peer" : "chat" }}
|
||||
colorKey={value.id}
|
||||
hasAvatar={value.hasAvatar}
|
||||
name={value.title}
|
||||
size={2.25}
|
||||
/>
|
||||
{:else}
|
||||
<span class="glyph"><Icon name={icon} size="1.5rem" /></span>
|
||||
{/if}
|
||||
<span class="text">
|
||||
<span class="label">{label}</span>
|
||||
<span class="value" class:placeholder={!value}>
|
||||
{value?.title ?? (required ? t("watches.choose") : empty)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if value && !required}
|
||||
<button
|
||||
aria-label={t("watches.clear")}
|
||||
class="clear"
|
||||
onclick={() => onpick(null)}
|
||||
title={t("watches.clear")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="close" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if open}
|
||||
<div class="picker">
|
||||
<input
|
||||
aria-label={people ? t("watches.searchPeople") : t("watches.search")}
|
||||
class="filter"
|
||||
oninput={() => picker.search(query)}
|
||||
placeholder={people ? t("watches.searchPeople") : t("watches.search")}
|
||||
type="text"
|
||||
bind:value={query}
|
||||
{@attach focus}
|
||||
>
|
||||
{#each results as chat (chat.chat_id)}
|
||||
<button class="pick" onclick={() => choose(chat)} type="button">
|
||||
<Avatar
|
||||
avatar={{
|
||||
id: chat.chat_id,
|
||||
kind: chat.chat_id > 0 ? "peer" : "chat",
|
||||
}}
|
||||
colorKey={chat.chat_id}
|
||||
hasAvatar={chat.has_avatar}
|
||||
name={chat.title ?? ""}
|
||||
size={2}
|
||||
/>
|
||||
<span class="pick-title">
|
||||
{chat.title ?? t("watches.unknownChat", { id: chat.chat_id })}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="nothing">{t("watches.nothing")}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.open .line {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
.main {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0.875rem;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.375rem 0.5rem 0.375rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
overflow: hidden;
|
||||
font-size: 0.9375rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.placeholder {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-interactive-element-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.filter {
|
||||
margin: 0.25rem 0.5rem 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders-input);
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pick {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small, 0.625rem);
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pick-title {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nothing {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "../ui/Button.svelte";
|
||||
|
||||
interface Props {
|
||||
onsave: (data: {
|
||||
kind: string;
|
||||
params: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}) => void;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
const { saving = false, onsave }: Props = $props();
|
||||
|
||||
const KINDS = [
|
||||
{ label: "Keyword", value: "keyword" },
|
||||
{ label: "Peer online", value: "peer_online" },
|
||||
];
|
||||
|
||||
let kind = $state("keyword");
|
||||
let keyword = $state("");
|
||||
let peerId = $state("");
|
||||
let enabled = $state(true);
|
||||
|
||||
const isKeyword = $derived(kind === "keyword");
|
||||
const valid = $derived(
|
||||
isKeyword ? keyword.trim() !== "" : peerId.trim() !== ""
|
||||
);
|
||||
|
||||
function submit() {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const params: Record<string, unknown> = isKeyword
|
||||
? { keyword: keyword.trim() }
|
||||
: { peer_id: Number(peerId.trim()) };
|
||||
onsave({ enabled, kind, params });
|
||||
keyword = "";
|
||||
peerId = "";
|
||||
enabled = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="watch-editor"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">Type</span>
|
||||
<select bind:value={kind}>
|
||||
{#each KINDS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if isKeyword}
|
||||
<label class="field">
|
||||
<span class="field-label">Keyword</span>
|
||||
<input placeholder="Text to match…" type="text" bind:value={keyword}>
|
||||
</label>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span class="field-label">Peer ID</span>
|
||||
<input
|
||||
inputmode="numeric"
|
||||
placeholder="123456789"
|
||||
type="text"
|
||||
bind:value={peerId}
|
||||
>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="field-inline">
|
||||
<input type="checkbox" bind:checked={enabled}>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
|
||||
<Button disabled={saving || !valid} type="submit">Add watch</Button>
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
.watch-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,388 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { ApiError } from "#lib/api/client.js";
|
||||
import {
|
||||
createWatch,
|
||||
updateWatch,
|
||||
type Watch,
|
||||
type WatchDraft,
|
||||
type WatchKind,
|
||||
type WatchParams,
|
||||
} from "#lib/api/watches.js";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import RadioList from "#lib/components/ui/RadioList.svelte";
|
||||
import {
|
||||
chatTitle,
|
||||
type Picked,
|
||||
patternOf,
|
||||
personName,
|
||||
WATCH_KINDS,
|
||||
} from "#lib/format/watches.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import ChatPickerField from "./ChatPickerField.svelte";
|
||||
|
||||
interface Props {
|
||||
oncancel: () => void;
|
||||
onsaved: (watch: Watch) => void;
|
||||
watch?: Watch | null;
|
||||
}
|
||||
|
||||
const { watch = null, oncancel, onsaved }: Props = $props();
|
||||
|
||||
const INVALID_REGEX = "invalid regex: ";
|
||||
const NESTED_REGEX = "regex repeats inside a repeat";
|
||||
|
||||
function pickedChat(source: Watch | null): Picked | null {
|
||||
const id = source?.params.chat_id;
|
||||
if (id === undefined || !source) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
hasAvatar: source.chat?.has_avatar ?? false,
|
||||
id,
|
||||
title: chatTitle(source.chat, id),
|
||||
};
|
||||
}
|
||||
|
||||
function pickedPerson(source: Watch | null): Picked | null {
|
||||
const id = source?.params.sender_id ?? source?.params.peer_id;
|
||||
if (id === undefined || !source) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
hasAvatar: source.person?.has_avatar ?? false,
|
||||
id,
|
||||
title: personName(source.person, id),
|
||||
};
|
||||
}
|
||||
|
||||
function initial(source: Watch | null) {
|
||||
return {
|
||||
chat: pickedChat(source),
|
||||
kind: source?.kind ?? "keyword",
|
||||
name: source?.name ?? "",
|
||||
pattern: source ? patternOf(source.params) : "",
|
||||
person: pickedPerson(source),
|
||||
regex: source?.params.regex ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const start = untrack(() => initial(watch));
|
||||
let kind = $state<WatchKind>(start.kind);
|
||||
let pattern = $state(start.pattern);
|
||||
let regex = $state(start.regex);
|
||||
let chat = $state<Picked | null>(start.chat);
|
||||
let person = $state<Picked | null>(start.person);
|
||||
let name = $state(start.name);
|
||||
let issue = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
const kinds = $derived(
|
||||
WATCH_KINDS.map((value) => ({ label: t(`watches.kind.${value}`), value }))
|
||||
);
|
||||
const scoped = $derived(kind === "keyword" || kind === "new_message");
|
||||
const chatEmpty = $derived(
|
||||
kind === "message_deleted" || kind === "message_edited"
|
||||
? t("watches.anyDm")
|
||||
: t("watches.anyChat")
|
||||
);
|
||||
|
||||
function params(): WatchParams {
|
||||
if (kind === "peer_online") {
|
||||
return { peer_id: person?.id };
|
||||
}
|
||||
const scope: WatchParams = {};
|
||||
if (chat) {
|
||||
scope.chat_id = chat.id;
|
||||
}
|
||||
if (scoped && person) {
|
||||
scope.sender_id = person.id;
|
||||
}
|
||||
if (kind === "keyword") {
|
||||
return { ...scope, pattern: pattern.trim(), regex };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function validate(): string | null {
|
||||
if (kind === "keyword" && pattern.trim() === "") {
|
||||
return t("watches.error.needPattern");
|
||||
}
|
||||
if (kind === "new_message" && !(chat || person)) {
|
||||
return t("watches.error.needScope");
|
||||
}
|
||||
if (kind === "peer_online" && !person) {
|
||||
return t("watches.error.needPerson");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function explain(failure: unknown): string {
|
||||
const detail =
|
||||
failure instanceof ApiError ? failure.detail : String(failure);
|
||||
if (detail.startsWith(INVALID_REGEX)) {
|
||||
return t("watches.error.badRegex", {
|
||||
detail: detail.slice(INVALID_REGEX.length),
|
||||
});
|
||||
}
|
||||
if (detail.startsWith(NESTED_REGEX)) {
|
||||
return t("watches.error.slowRegex");
|
||||
}
|
||||
return t("watches.error.failed", { detail });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
issue = validate();
|
||||
if (issue) {
|
||||
return;
|
||||
}
|
||||
const draft: WatchDraft = {
|
||||
enabled: watch?.enabled ?? true,
|
||||
kind,
|
||||
name: name.trim() || null,
|
||||
params: params(),
|
||||
};
|
||||
saving = true;
|
||||
try {
|
||||
onsaved(
|
||||
watch ? await updateWatch(watch.id, draft) : await createWatch(draft)
|
||||
);
|
||||
} catch (failure) {
|
||||
issue = explain(failure);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="watch-form"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save();
|
||||
}}
|
||||
>
|
||||
{#if !watch}
|
||||
<div class="section-title">{t("watches.kind")}</div>
|
||||
<RadioList
|
||||
onselect={(value) => {
|
||||
kind = value;
|
||||
issue = null;
|
||||
}}
|
||||
options={kinds}
|
||||
selected={kind}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="fields">
|
||||
{#if kind === "keyword"}
|
||||
<div class="input-group" class:touched={pattern !== ""}>
|
||||
<input
|
||||
class="form-control"
|
||||
id="watch-pattern-{watch?.id ?? "new"}"
|
||||
maxlength="200"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
bind:value={pattern}
|
||||
>
|
||||
<label for="watch-pattern-{watch?.id ?? "new"}">
|
||||
{regex ? t("watches.expression") : t("watches.pattern")}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
aria-checked={regex}
|
||||
class="toggle"
|
||||
onclick={() => {
|
||||
regex = !regex;
|
||||
issue = null;
|
||||
}}
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span class="toggle-label">{t("watches.regex")}</span>
|
||||
<span class="switch" class:on={regex}><span class="knob"></span></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if kind === "peer_online"}
|
||||
<ChatPickerField
|
||||
empty={t("watches.choose")}
|
||||
icon="user"
|
||||
label={t("watches.person")}
|
||||
onpick={(value) => {
|
||||
person = value;
|
||||
issue = null;
|
||||
}}
|
||||
people
|
||||
required
|
||||
value={person}
|
||||
/>
|
||||
{:else}
|
||||
<ChatPickerField
|
||||
empty={chatEmpty}
|
||||
icon="group"
|
||||
label={t("watches.chat")}
|
||||
onpick={(value) => {
|
||||
chat = value;
|
||||
issue = null;
|
||||
}}
|
||||
value={chat}
|
||||
/>
|
||||
{#if scoped}
|
||||
<ChatPickerField
|
||||
empty={t("watches.anySender")}
|
||||
icon="user"
|
||||
label={t("watches.sender")}
|
||||
onpick={(value) => {
|
||||
person = value;
|
||||
issue = null;
|
||||
}}
|
||||
people
|
||||
value={person}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="fields">
|
||||
<div class="input-group name" class:touched={name !== ""}>
|
||||
<input
|
||||
class="form-control"
|
||||
id="watch-name-{watch?.id ?? "new"}"
|
||||
maxlength="120"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
>
|
||||
<label for="watch-name-{watch?.id ?? "new"}">{t("watches.name")}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if issue}
|
||||
<p class="error">{issue}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<Button onclick={oncancel} smaller variant="text">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={saving} pill smaller type="submit">
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
.watch-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.875rem 1rem 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fields {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
|
||||
&.name {
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
width: calc(100% + 2rem);
|
||||
min-height: 2.75rem;
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
border: 0;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 1rem 0;
|
||||
|
||||
:global(.Button) {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Watch } from "#lib/api/types.js";
|
||||
import { peerName } from "#lib/format/peer.js";
|
||||
import { peers } from "#lib/stores/peers.svelte.js";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import Icon from "../ui/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
ondelete: (watch: Watch) => void;
|
||||
ontoggle: (watch: Watch) => void;
|
||||
watches: Watch[];
|
||||
}
|
||||
|
||||
const { watches, ontoggle, ondelete }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const ids = watches
|
||||
.filter((w) => w.kind === "peer_online")
|
||||
.map((w) => Number(w.params.peer_id))
|
||||
.filter((id) => Number.isFinite(id));
|
||||
peers.ensure(ids);
|
||||
});
|
||||
|
||||
function summary(watch: Watch): string {
|
||||
if (watch.kind === "keyword") {
|
||||
return `“${String(watch.params.keyword ?? "")}”`;
|
||||
}
|
||||
const id = Number(watch.params.peer_id);
|
||||
return peerName(peers.get(id) ?? null) || `#${id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="watch-list">
|
||||
{#each watches as watch (watch.id)}
|
||||
<li class="watch-item" class:disabled={!watch.enabled}>
|
||||
<div class="watch-main">
|
||||
<span class="watch-kind">{watch.kind}</span>
|
||||
<span class="watch-summary">{summary(watch)}</span>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<Button
|
||||
aria-label="Переключить"
|
||||
onclick={() => ontoggle(watch)}
|
||||
round
|
||||
smaller
|
||||
variant="translucent"
|
||||
>
|
||||
<Icon name={watch.enabled ? "eye" : "eye-crossed"} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Удалить"
|
||||
onclick={() => ondelete(watch)}
|
||||
round
|
||||
smaller
|
||||
variant="translucent"
|
||||
>
|
||||
<Icon name="delete" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
.watch-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.watch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.watch-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.watch-kind {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.watch-summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +1,475 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
createWatch,
|
||||
deleteWatch,
|
||||
listWatches,
|
||||
listWebhooks,
|
||||
updateWatch,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type { Watch } from "#lib/api/types.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import Spinner from "../ui/Spinner.svelte";
|
||||
import WatchEditor from "./WatchEditor.svelte";
|
||||
import WatchList from "./WatchList.svelte";
|
||||
type Watch,
|
||||
} from "#lib/api/watches.js";
|
||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||
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 { formatListDate } from "#lib/format/datetime.js";
|
||||
import {
|
||||
chatTitle,
|
||||
describeWatch,
|
||||
KIND_ICONS,
|
||||
personName,
|
||||
} from "#lib/format/watches.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import WatchForm from "./WatchForm.svelte";
|
||||
|
||||
let watches = $state<Watch[]>([]);
|
||||
let hookCount = $state(0);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let failed = $state(false);
|
||||
let editing = $state<number | "new" | null>(null);
|
||||
let confirming = $state<number | null>(null);
|
||||
let problem = $state<string | null>(null);
|
||||
let token = 0;
|
||||
|
||||
async function load(_account: number | null) {
|
||||
loading = true;
|
||||
async function load() {
|
||||
token += 1;
|
||||
const current = token;
|
||||
try {
|
||||
watches = await listWatches();
|
||||
const [rows, hooks] = await Promise.all([listWatches(), listWebhooks()]);
|
||||
if (current === token) {
|
||||
watches = rows;
|
||||
hookCount = hooks.length;
|
||||
failed = false;
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
failed = true;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
if (current === token) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(accounts.selectedId);
|
||||
});
|
||||
onMount(load);
|
||||
|
||||
async function add(data: {
|
||||
kind: string;
|
||||
params: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
saving = true;
|
||||
try {
|
||||
const created = await createWatch(data.kind, data.params, data.enabled);
|
||||
watches = [created, ...watches];
|
||||
} finally {
|
||||
saving = false;
|
||||
function stats(watch: Watch): string {
|
||||
if (watch.alert_count === 0 || watch.last_alert_at === null) {
|
||||
return t("watches.never");
|
||||
}
|
||||
return t("watches.fired", {
|
||||
count: watch.alert_count,
|
||||
date: formatListDate(watch.last_alert_at),
|
||||
});
|
||||
}
|
||||
|
||||
function subtitle(watch: Watch): string {
|
||||
const line = watch.name ? describeWatch(watch) : stats(watch);
|
||||
return watch.enabled ? line : `${t("watches.off")} · ${line}`;
|
||||
}
|
||||
|
||||
function saved(watch: Watch) {
|
||||
const known = watches.some((item) => item.id === watch.id);
|
||||
watches = known
|
||||
? watches.map((item) => (item.id === watch.id ? watch : item))
|
||||
: [watch, ...watches];
|
||||
editing = null;
|
||||
}
|
||||
|
||||
async function toggle(watch: Watch) {
|
||||
const updated = await updateWatch(watch.id, watch.params, !watch.enabled);
|
||||
watches = watches.map((w) => (w.id === updated.id ? updated : w));
|
||||
problem = null;
|
||||
try {
|
||||
saved(
|
||||
await updateWatch(watch.id, {
|
||||
enabled: !watch.enabled,
|
||||
kind: watch.kind,
|
||||
name: watch.name,
|
||||
params: watch.params,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
problem = t("watches.error.action");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(watch: Watch) {
|
||||
await deleteWatch(watch.id);
|
||||
watches = watches.filter((w) => w.id !== watch.id);
|
||||
problem = null;
|
||||
try {
|
||||
await deleteWatch(watch.id);
|
||||
watches = watches.filter((item) => item.id !== watch.id);
|
||||
} catch {
|
||||
problem = t("watches.error.action");
|
||||
} finally {
|
||||
confirming = null;
|
||||
}
|
||||
}
|
||||
|
||||
function edit(watch: Watch) {
|
||||
confirming = null;
|
||||
editing = editing === watch.id ? null : watch.id;
|
||||
}
|
||||
|
||||
function reveal(node: HTMLElement) {
|
||||
node.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel-content">
|
||||
<WatchEditor onsave={add} {saving} />
|
||||
{#snippet lead(
|
||||
watch: Watch
|
||||
)}
|
||||
{@const chatId = watch.params.chat_id}
|
||||
{@const personId = watch.params.sender_id ?? watch.params.peer_id}
|
||||
{#if chatId !== undefined}
|
||||
<Avatar
|
||||
avatar={{ id: chatId, kind: chatId > 0 ? "peer" : "chat" }}
|
||||
colorKey={chatId}
|
||||
hasAvatar={watch.chat?.has_avatar ?? false}
|
||||
name={chatTitle(watch.chat, chatId)}
|
||||
size={2.625}
|
||||
/>
|
||||
{:else if personId !== undefined}
|
||||
<Avatar
|
||||
avatar={{ id: personId, kind: "peer" }}
|
||||
colorKey={personId}
|
||||
hasAvatar={watch.person?.has_avatar ?? false}
|
||||
name={personName(watch.person, personId)}
|
||||
size={2.625}
|
||||
/>
|
||||
{:else}
|
||||
<span class="glyph"
|
||||
><Icon name={KIND_ICONS[watch.kind]} size="1.5rem" /></span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="watches">
|
||||
<button
|
||||
aria-expanded={editing === "new"}
|
||||
class="add"
|
||||
onclick={() => {
|
||||
editing = editing === "new" ? null : "new";
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>{t("watches.add")}</span>
|
||||
</button>
|
||||
{#if editing === "new"}
|
||||
<div {@attach reveal}>
|
||||
<WatchForm
|
||||
oncancel={() => {
|
||||
editing = null;
|
||||
}}
|
||||
onsaved={saved}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if problem}
|
||||
<p class="problem">{problem}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else if failed}
|
||||
<div class="state">
|
||||
<p>{t("watches.loadFailed")}</p>
|
||||
<Button onclick={load} smaller variant="text"
|
||||
>{t("watches.retry")}</Button
|
||||
>
|
||||
</div>
|
||||
{:else if watches.length === 0}
|
||||
<p class="empty">Пока нет отслеживаний.</p>
|
||||
<div class="empty">
|
||||
<EmptyState
|
||||
description={t("watches.empty.description")}
|
||||
title={t("watches.empty.title")}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<WatchList ondelete={remove} ontoggle={toggle} {watches} />
|
||||
<ul class="list">
|
||||
{#each watches as watch (watch.id)}
|
||||
<li
|
||||
class="item"
|
||||
class:off={!watch.enabled}
|
||||
class:open={editing === watch.id}
|
||||
>
|
||||
{#if confirming === watch.id}
|
||||
<div class="confirm">
|
||||
<p>{t("watches.deleteConfirm")}</p>
|
||||
<div class="confirm-actions">
|
||||
<Button
|
||||
onclick={() => {
|
||||
confirming = null;
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onclick={() => remove(watch)} smaller variant="danger">
|
||||
{t("watches.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ContextMenu>
|
||||
{#snippet children({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
aria-expanded={editing === watch.id}
|
||||
class="row"
|
||||
onclick={() => edit(watch)}
|
||||
type="button"
|
||||
>
|
||||
<span class="lead">{@render lead(watch)}</span>
|
||||
<span class="text">
|
||||
<span class="title"
|
||||
>{watch.name ?? describeWatch(watch)}</span
|
||||
>
|
||||
<span class="sub">{subtitle(watch)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem icon="edit" onselect={() => edit(watch)}>
|
||||
{t("watches.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={watch.enabled ? "eye-crossed" : "eye"}
|
||||
onselect={() => toggle(watch)}
|
||||
>
|
||||
{watch.enabled ? t("watches.disable") : t("watches.enable")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon="delete"
|
||||
onselect={() => {
|
||||
editing = null;
|
||||
confirming = watch.id;
|
||||
}}
|
||||
>
|
||||
{t("watches.delete")}
|
||||
</ContextMenuItem>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
{#if editing === watch.id}
|
||||
<div {@attach reveal}>
|
||||
<WatchForm
|
||||
oncancel={() => {
|
||||
editing = null;
|
||||
}}
|
||||
onsaved={saved}
|
||||
{watch}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<SettingsItem
|
||||
icon="web"
|
||||
label={t("watches.webhooks")}
|
||||
onclick={() => ui.openPanel("webhooks")}
|
||||
value={hookCount > 0 ? String(hookCount) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.panel-content {
|
||||
.watches {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.add {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
padding: 0 1rem 0 1.5rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
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);
|
||||
padding: 2.5rem 1rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item.open {
|
||||
.row {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.off .lead,
|
||||
.off .title {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title,
|
||||
.sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
min-height: 3.75rem;
|
||||
padding: 0.625rem 1rem 0.375rem;
|
||||
|
||||
background-color: var(--color-chat-hover);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
|
||||
:global(.Button) {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--color-borders);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,676 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { ApiError } from "#lib/api/client.js";
|
||||
import {
|
||||
type Delivery,
|
||||
listDeliveries,
|
||||
rotateWebhookSecret,
|
||||
testWebhook,
|
||||
updateWebhook,
|
||||
type Watch,
|
||||
type Webhook,
|
||||
type WebhookDraft,
|
||||
} from "#lib/api/watches.js";
|
||||
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 { formatListDate } from "#lib/format/datetime.js";
|
||||
import { deliveryStatus, describeWatch } from "#lib/format/watches.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { alerts } from "#lib/stores/alerts.svelte.js";
|
||||
|
||||
interface Props {
|
||||
intent?: "test" | "delete" | null;
|
||||
onchange: (webhook: Webhook) => void;
|
||||
ondelete: () => void;
|
||||
watches: Watch[];
|
||||
webhook: Webhook;
|
||||
}
|
||||
|
||||
const {
|
||||
webhook,
|
||||
watches,
|
||||
onchange,
|
||||
ondelete,
|
||||
intent = null,
|
||||
}: Props = $props();
|
||||
|
||||
const COPIED_MS = 1500;
|
||||
const LOG_DELAY_MS = 1500;
|
||||
const STATUS_ICONS: Record<Delivery["status"], string> = {
|
||||
delivered: "check",
|
||||
failed: "warning",
|
||||
pending: "recent",
|
||||
sending: "recent",
|
||||
};
|
||||
|
||||
let url = $state(untrack(() => webhook.url));
|
||||
let log = $state<Delivery[]>([]);
|
||||
let logLoaded = $state(false);
|
||||
let copied = $state(false);
|
||||
let testing = $state(false);
|
||||
let result = $state<Delivery | null>(null);
|
||||
let problem = $state<string | null>(null);
|
||||
let confirming = $state(false);
|
||||
|
||||
const dirty = $derived(url.trim() !== webhook.url);
|
||||
const chosen = $derived(new Set(webhook.watch_ids ?? []));
|
||||
|
||||
async function loadLog() {
|
||||
try {
|
||||
log = await listDeliveries(webhook.id);
|
||||
} finally {
|
||||
logLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function draft(changes: Partial<WebhookDraft>): WebhookDraft {
|
||||
return {
|
||||
enabled: webhook.enabled,
|
||||
url: webhook.url,
|
||||
watch_ids: webhook.watch_ids,
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
||||
async function save(changes: Partial<WebhookDraft>) {
|
||||
problem = null;
|
||||
try {
|
||||
onchange(await updateWebhook(webhook.id, draft(changes)));
|
||||
} catch (failure) {
|
||||
problem =
|
||||
failure instanceof ApiError && failure.status === 422
|
||||
? t("webhooks.badUrl")
|
||||
: t("watches.error.action");
|
||||
}
|
||||
}
|
||||
|
||||
function pick(watchId: number | null) {
|
||||
if (watchId === null) {
|
||||
save({ watch_ids: null });
|
||||
return;
|
||||
}
|
||||
const current = webhook.watch_ids ?? [];
|
||||
const next = current.includes(watchId)
|
||||
? current.filter((id) => id !== watchId)
|
||||
: [...current, watchId];
|
||||
save({ watch_ids: next.length > 0 ? next : null });
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(webhook.secret);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, COPIED_MS);
|
||||
}
|
||||
|
||||
async function rotate() {
|
||||
problem = null;
|
||||
try {
|
||||
onchange(await rotateWebhookSecret(webhook.id));
|
||||
} catch {
|
||||
problem = t("watches.error.action");
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
testing = true;
|
||||
result = null;
|
||||
problem = null;
|
||||
try {
|
||||
result = await testWebhook(webhook.id);
|
||||
await loadLog();
|
||||
} catch {
|
||||
problem = t("watches.error.action");
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function detail(delivery: Delivery): string {
|
||||
const line = deliveryStatus(delivery);
|
||||
return delivery.attempts > 1
|
||||
? `${line} · ${t("webhooks.attempts", { count: delivery.attempts })}`
|
||||
: line;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadLog();
|
||||
if (intent === "test") {
|
||||
test();
|
||||
}
|
||||
confirming = intent === "delete";
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stop = alerts.onAlert(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(loadLog, LOG_DELAY_MS);
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet choice(
|
||||
label: string,
|
||||
checked: boolean,
|
||||
watchId: number | null
|
||||
)}
|
||||
<label class="choice">
|
||||
<input
|
||||
{checked}
|
||||
class="native"
|
||||
onchange={() => pick(watchId)}
|
||||
type="checkbox"
|
||||
>
|
||||
<span class="circle" class:on={checked}>
|
||||
<Icon name="check" size="0.875rem" />
|
||||
</span>
|
||||
<span class="choice-label">{label}</span>
|
||||
</label>
|
||||
{/snippet}
|
||||
|
||||
<div class="card">
|
||||
<div class="fields">
|
||||
<div class="input-group touched">
|
||||
<input
|
||||
class="form-control"
|
||||
id="webhook-url-{webhook.id}"
|
||||
inputmode="url"
|
||||
spellcheck="false"
|
||||
type="url"
|
||||
bind:value={url}
|
||||
>
|
||||
<label for="webhook-url-{webhook.id}">{t("webhooks.url")}</label>
|
||||
</div>
|
||||
{#if dirty}
|
||||
<div class="row-actions">
|
||||
<Button
|
||||
onclick={() => {
|
||||
({ url } = webhook);
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onclick={() => save({ url: url.trim() })} pill smaller>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="secret">
|
||||
<div class="secret-text">
|
||||
<span class="secret-label">{t("webhooks.secret")}</span>
|
||||
<code class="secret-value">{webhook.secret}</code>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("webhooks.copy")}
|
||||
class="icon-button"
|
||||
onclick={copy}
|
||||
title={copied ? t("webhooks.copied") : t("webhooks.copy")}
|
||||
type="button"
|
||||
class:done={copied}
|
||||
>
|
||||
<Icon name={copied ? "check" : "copy"} size="1.25rem" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
{copied ? t("webhooks.copied") : t("webhooks.signature")}
|
||||
</p>
|
||||
<div class="row-actions start">
|
||||
<Button onclick={rotate} smaller variant="text">
|
||||
{t("webhooks.rotate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-checked={webhook.enabled}
|
||||
class="toggle"
|
||||
onclick={() => save({ enabled: !webhook.enabled })}
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span class="toggle-label">{t("webhooks.enabled")}</span>
|
||||
<span class="switch" class:on={webhook.enabled}>
|
||||
<span class="knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="section-title">{t("webhooks.filter")}</div>
|
||||
{@render choice(t("webhooks.allWatches"), chosen.size === 0, null)}
|
||||
{#each watches as watch (watch.id)}
|
||||
{@render choice(
|
||||
watch.name ?? describeWatch(watch),
|
||||
chosen.has(watch.id),
|
||||
watch.id
|
||||
)}
|
||||
{/each}
|
||||
|
||||
{#if problem}
|
||||
<p class="problem">{problem}</p>
|
||||
{/if}
|
||||
|
||||
{#if confirming}
|
||||
<div class="confirm">
|
||||
<p>{t("webhooks.deleteConfirm")}</p>
|
||||
<div class="row-actions">
|
||||
<Button
|
||||
onclick={() => {
|
||||
confirming = false;
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onclick={ondelete} smaller variant="danger">
|
||||
{t("watches.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="row-actions split">
|
||||
<Button
|
||||
onclick={() => {
|
||||
confirming = true;
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
<span class="danger-text">{t("watches.delete")}</span>
|
||||
</Button>
|
||||
<Button disabled={testing} onclick={test} pill smaller>
|
||||
{testing ? t("webhooks.testing") : t("webhooks.test")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if result}
|
||||
<p class="result" class:failed={result.status !== "delivered"}>
|
||||
{result.status === "delivered"
|
||||
? t("webhooks.testOk", { code: result.response_code ?? "" })
|
||||
: t("webhooks.testFailed", {
|
||||
error: result.last_error ?? String(result.response_code ?? ""),
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t("webhooks.log")}</div>
|
||||
{#if !logLoaded}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else if log.length === 0}
|
||||
<p class="nothing">{t("webhooks.logEmpty")}</p>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each log as delivery (delivery.id)}
|
||||
<li class="entry {delivery.status}">
|
||||
<span class="entry-icon">
|
||||
<Icon name={STATUS_ICONS[delivery.status]} size="1.125rem" />
|
||||
</span>
|
||||
<span class="entry-text">
|
||||
<span class="entry-title">
|
||||
{t(`webhooks.event.${delivery.event}`)}
|
||||
{#if delivery.alert_id !== null}
|
||||
<span class="entry-id">#{delivery.alert_id}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="entry-detail">{detail(delivery)}</span>
|
||||
</span>
|
||||
<time class="entry-time" datetime={delivery.created_at}>
|
||||
{formatListDate(delivery.created_at)}
|
||||
</time>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0 0.75rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.fields {
|
||||
padding: 0.5rem 1rem 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1rem;
|
||||
|
||||
&.start {
|
||||
justify-content: flex-start;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
&.split {
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
:global(.Button) {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fields .row-actions {
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.secret {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
margin: 0.25rem 1rem 0;
|
||||
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.secret-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.secret-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.secret-value {
|
||||
overflow: hidden;
|
||||
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
background: none;
|
||||
|
||||
&.done {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-interactive-element-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0.375rem 1rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toggle,
|
||||
.choice {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0 1rem;
|
||||
border: 0;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.native {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.choice {
|
||||
position: relative;
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-label,
|
||||
.choice-label {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border: 2px solid var(--color-borders-input);
|
||||
border-radius: 50%;
|
||||
|
||||
color: transparent;
|
||||
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.875rem 1rem 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.problem,
|
||||
.result {
|
||||
margin: 0;
|
||||
padding: 0.375rem 1rem 0;
|
||||
font-size: 0.8125rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.problem,
|
||||
.result.failed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.result {
|
||||
text-align: end;
|
||||
color: var(--color-text-green);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0 0;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.nothing {
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
.delivered & {
|
||||
color: var(--color-text-green);
|
||||
}
|
||||
|
||||
.failed & {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.entry-id {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.entry-detail {
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { ApiError } from "#lib/api/client.js";
|
||||
import {
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
listWatches,
|
||||
listWebhooks,
|
||||
updateWebhook,
|
||||
type Watch,
|
||||
type Webhook,
|
||||
} from "#lib/api/watches.js";
|
||||
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 { formatListDate } from "#lib/format/datetime.js";
|
||||
import { deliveryStatus } from "#lib/format/watches.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import WebhookCard from "./WebhookCard.svelte";
|
||||
|
||||
type Intent = "test" | "delete";
|
||||
|
||||
const SCHEME = /^https?:\/\//;
|
||||
|
||||
let hooks = $state<Webhook[]>([]);
|
||||
let watches = $state<Watch[]>([]);
|
||||
let loading = $state(true);
|
||||
let failed = $state(false);
|
||||
let expanded = $state<number | null>(null);
|
||||
let intent = $state<{ action: Intent; id: number } | null>(null);
|
||||
let adding = $state(false);
|
||||
let newUrl = $state("");
|
||||
let addError = $state<string | null>(null);
|
||||
let problem = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
[hooks, watches] = await Promise.all([listWebhooks(), listWatches()]);
|
||||
failed = false;
|
||||
} catch {
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(next: Webhook) {
|
||||
hooks = hooks.map((hook) => (hook.id === next.id ? next : hook));
|
||||
}
|
||||
|
||||
function status(hook: Webhook): string {
|
||||
const last = hook.last_delivery;
|
||||
const line = last
|
||||
? `${deliveryStatus(last)} · ${formatListDate(last.created_at)}`
|
||||
: t("webhooks.never");
|
||||
return hook.enabled ? line : `${t("webhooks.off")} · ${line}`;
|
||||
}
|
||||
|
||||
async function add() {
|
||||
addError = null;
|
||||
try {
|
||||
const hook = await createWebhook({
|
||||
enabled: true,
|
||||
url: newUrl.trim(),
|
||||
watch_ids: null,
|
||||
});
|
||||
hooks = [...hooks, hook];
|
||||
adding = false;
|
||||
newUrl = "";
|
||||
expanded = hook.id;
|
||||
} catch (failure) {
|
||||
addError =
|
||||
failure instanceof ApiError && failure.status === 422
|
||||
? t("webhooks.badUrl")
|
||||
: t("watches.error.action");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(hook: Webhook) {
|
||||
problem = null;
|
||||
try {
|
||||
replace(
|
||||
await updateWebhook(hook.id, {
|
||||
enabled: !hook.enabled,
|
||||
url: hook.url,
|
||||
watch_ids: hook.watch_ids,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
problem = t("watches.error.action");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(hook: Webhook) {
|
||||
problem = null;
|
||||
try {
|
||||
await deleteWebhook(hook.id);
|
||||
hooks = hooks.filter((item) => item.id !== hook.id);
|
||||
expanded = null;
|
||||
} catch {
|
||||
problem = t("watches.error.action");
|
||||
}
|
||||
}
|
||||
|
||||
function open(hook: Webhook, action: Intent | null = null) {
|
||||
intent = action ? { action, id: hook.id } : null;
|
||||
expanded = expanded === hook.id && !action ? null : hook.id;
|
||||
}
|
||||
|
||||
function reveal(node: HTMLElement) {
|
||||
node.parentElement?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="webhooks">
|
||||
<button
|
||||
aria-expanded={adding}
|
||||
class="add"
|
||||
onclick={() => {
|
||||
adding = !adding;
|
||||
addError = null;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>{t("webhooks.add")}</span>
|
||||
</button>
|
||||
{#if adding}
|
||||
<form
|
||||
class="new"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
add();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="input-group"
|
||||
class:error={addError}
|
||||
class:touched={newUrl !== ""}
|
||||
>
|
||||
<input
|
||||
class="form-control"
|
||||
id="webhook-new-url"
|
||||
inputmode="url"
|
||||
placeholder="https://"
|
||||
spellcheck="false"
|
||||
type="url"
|
||||
bind:value={newUrl}
|
||||
>
|
||||
<label for="webhook-new-url">{addError ?? t("webhooks.url")}</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Button
|
||||
onclick={() => {
|
||||
adding = false;
|
||||
newUrl = "";
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{#if newUrl.trim() !== ""}
|
||||
<Button pill smaller type="submit">{t("common.save")}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if problem}
|
||||
<p class="problem">{problem}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else if failed}
|
||||
<div class="state">
|
||||
<p>{t("watches.loadFailed")}</p>
|
||||
<Button onclick={load} smaller variant="text"
|
||||
>{t("watches.retry")}</Button
|
||||
>
|
||||
</div>
|
||||
{:else if hooks.length === 0 && !adding}
|
||||
<div class="empty">
|
||||
<EmptyState
|
||||
description={t("webhooks.empty.description")}
|
||||
title={t("webhooks.empty.title")}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each hooks as hook (hook.id)}
|
||||
<li
|
||||
class="item"
|
||||
class:off={!hook.enabled}
|
||||
class:open={expanded === hook.id}
|
||||
>
|
||||
<ContextMenu>
|
||||
{#snippet children({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
aria-expanded={expanded === hook.id}
|
||||
class="row"
|
||||
onclick={() => open(hook)}
|
||||
type="button"
|
||||
>
|
||||
<span class="glyph"><Icon name="web" size="1.5rem" /></span>
|
||||
<span class="text">
|
||||
<span class="title">{hook.url.replace(SCHEME, "")}</span>
|
||||
<span
|
||||
class="sub"
|
||||
class:failed={hook.last_delivery?.status === "failed"}
|
||||
>
|
||||
{status(hook)}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name={expanded === hook.id ? "up" : "down"}
|
||||
size="1.125rem"
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem icon="send" onselect={() => open(hook, "test")}>
|
||||
{t("webhooks.test")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={hook.enabled ? "eye-crossed" : "eye"}
|
||||
onselect={() => toggle(hook)}
|
||||
>
|
||||
{hook.enabled ? t("watches.disable") : t("watches.enable")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon="delete"
|
||||
onselect={() => open(hook, "delete")}
|
||||
>
|
||||
{t("watches.delete")}
|
||||
</ContextMenuItem>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
{#if expanded === hook.id}
|
||||
{#key intent}
|
||||
<div {@attach reveal}>
|
||||
<WebhookCard
|
||||
intent={intent?.id === hook.id ? intent.action : null}
|
||||
onchange={replace}
|
||||
ondelete={() => remove(hook)}
|
||||
{watches}
|
||||
webhook={hook}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.webhooks {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.add {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
padding: 0 1rem 0 1.5rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new {
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
|
||||
:global(.Button) {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
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 {
|
||||
padding: 2.5rem 1rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item.open .row {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
.row {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
:global(.icon) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.off .glyph,
|
||||
.off .title {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title,
|
||||
.sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.failed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user