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
+3696
-455
No files matched your search
@@ -1,7 +1,6 @@
|
|||||||
import { request } from "#lib/api/client.js";
|
import { request } from "#lib/api/client.js";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Alert,
|
|
||||||
Annotation,
|
Annotation,
|
||||||
AvatarHistoryView,
|
AvatarHistoryView,
|
||||||
CallbackView,
|
CallbackView,
|
||||||
@@ -45,7 +44,6 @@ import type {
|
|||||||
TrashItem,
|
TrashItem,
|
||||||
TrashOutcome,
|
TrashOutcome,
|
||||||
VolumeBucket,
|
VolumeBucket,
|
||||||
Watch,
|
|
||||||
WatchedBot,
|
WatchedBot,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
@@ -547,46 +545,6 @@ export function transcribeMedia(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listWatches(): Promise<Watch[]> {
|
|
||||||
return request<Watch[]>("/watches", { account: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWatch(
|
|
||||||
kind: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
enabled: boolean
|
|
||||||
): Promise<Watch> {
|
|
||||||
return request<Watch>("/watches", {
|
|
||||||
body: { account_id: accounts.selectedId, enabled, kind, params },
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateWatch(
|
|
||||||
id: number,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
enabled: boolean
|
|
||||||
): Promise<Watch> {
|
|
||||||
return request<Watch>(`/watches/${id}`, {
|
|
||||||
body: { enabled, params },
|
|
||||||
method: "PUT",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteWatch(id: number): Promise<void> {
|
|
||||||
return request<void>(`/watches/${id}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAlerts(
|
|
||||||
options: Page & { seen?: boolean } = {}
|
|
||||||
): Promise<Alert[]> {
|
|
||||||
return request<Alert[]>("/alerts", { account: true, query: { ...options } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markAlertSeen(id: number): Promise<void> {
|
|
||||||
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAnnotations(
|
export function listAnnotations(
|
||||||
options: { chatId?: number; messageId?: number } = {}
|
options: { chatId?: number; messageId?: number } = {}
|
||||||
): Promise<Annotation[]> {
|
): Promise<Annotation[]> {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LiveAlertEvent } from "#lib/api/watches.js";
|
||||||
|
|
||||||
export type ChatKind = "private" | "group";
|
export type ChatKind = "private" | "group";
|
||||||
export type PolicyScopeType =
|
export type PolicyScopeType =
|
||||||
| "default_dm"
|
| "default_dm"
|
||||||
@@ -543,26 +545,6 @@ export interface Folder {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Watch {
|
|
||||||
account_id: number;
|
|
||||||
created_at: string;
|
|
||||||
enabled: boolean;
|
|
||||||
id: number;
|
|
||||||
kind: string;
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alert {
|
|
||||||
account_id: number;
|
|
||||||
created_at: string;
|
|
||||||
id: number;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
seen: boolean;
|
|
||||||
ts: string;
|
|
||||||
watch_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobView {
|
export interface JobView {
|
||||||
account_id: number;
|
account_id: number;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
@@ -607,7 +589,8 @@ export type LiveEvent =
|
|||||||
| LiveMessageEvent
|
| LiveMessageEvent
|
||||||
| LiveDeleteEvent
|
| LiveDeleteEvent
|
||||||
| LivePresenceEvent
|
| LivePresenceEvent
|
||||||
| LiveReceiptEvent;
|
| LiveReceiptEvent
|
||||||
|
| LiveAlertEvent;
|
||||||
|
|
||||||
export interface FileShare {
|
export interface FileShare {
|
||||||
account_id: number;
|
account_id: number;
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { request } from "#lib/api/client.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
|
||||||
|
export type WatchKind =
|
||||||
|
| "keyword"
|
||||||
|
| "new_message"
|
||||||
|
| "message_deleted"
|
||||||
|
| "message_edited"
|
||||||
|
| "peer_online";
|
||||||
|
|
||||||
|
export interface WatchParams {
|
||||||
|
chat_id?: number;
|
||||||
|
pattern?: string;
|
||||||
|
peer_id?: number;
|
||||||
|
regex?: boolean;
|
||||||
|
sender_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoredParams = WatchParams & { keyword?: string };
|
||||||
|
|
||||||
|
export interface ChatRef {
|
||||||
|
has_avatar: boolean;
|
||||||
|
id: number;
|
||||||
|
kind: "private" | "group" | "channel";
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonRef {
|
||||||
|
has_avatar: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Watch {
|
||||||
|
account_id: number;
|
||||||
|
alert_count: number;
|
||||||
|
chat: ChatRef | null;
|
||||||
|
created_at: string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
kind: WatchKind;
|
||||||
|
last_alert_at: string | null;
|
||||||
|
name: string | null;
|
||||||
|
params: StoredParams;
|
||||||
|
person: PersonRef | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchDraft {
|
||||||
|
enabled: boolean;
|
||||||
|
kind: WatchKind;
|
||||||
|
name: string | null;
|
||||||
|
params: WatchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertEvent =
|
||||||
|
| "message"
|
||||||
|
| "edit"
|
||||||
|
| "transcript"
|
||||||
|
| "delete"
|
||||||
|
| "online";
|
||||||
|
|
||||||
|
export interface AlertMessage {
|
||||||
|
date: string | null;
|
||||||
|
deleted: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
id: number;
|
||||||
|
sender: PersonRef | null;
|
||||||
|
text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
account_id: number;
|
||||||
|
away_since: string | null;
|
||||||
|
chat: ChatRef | null;
|
||||||
|
event: AlertEvent;
|
||||||
|
id: number;
|
||||||
|
message: AlertMessage | null;
|
||||||
|
path: string;
|
||||||
|
peer: PersonRef | null;
|
||||||
|
seen: boolean;
|
||||||
|
ts: string;
|
||||||
|
watch: {
|
||||||
|
id: number;
|
||||||
|
kind: WatchKind;
|
||||||
|
name: string | null;
|
||||||
|
params: StoredParams;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertPage {
|
||||||
|
items: AlertItem[];
|
||||||
|
next_before: number | null;
|
||||||
|
unseen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveAlertEvent {
|
||||||
|
alert: AlertItem | null;
|
||||||
|
type: "alert";
|
||||||
|
unseen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Webhook {
|
||||||
|
account_id: number;
|
||||||
|
created_at: string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
last_delivery: Delivery | null;
|
||||||
|
secret: string;
|
||||||
|
updated_at: string;
|
||||||
|
url: string;
|
||||||
|
watch_ids: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookDraft {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
watch_ids: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Delivery {
|
||||||
|
alert_id: number | null;
|
||||||
|
attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
event: "alert" | "test";
|
||||||
|
finished_at: string | null;
|
||||||
|
id: number;
|
||||||
|
last_error: string | null;
|
||||||
|
next_attempt_at: string;
|
||||||
|
response_code: number | null;
|
||||||
|
status: "pending" | "sending" | "delivered" | "failed";
|
||||||
|
webhook_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWatches(): Promise<Watch[]> {
|
||||||
|
return request<Watch[]>("/watches", { account: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWatch(draft: WatchDraft): Promise<Watch> {
|
||||||
|
return request<Watch>("/watches", {
|
||||||
|
body: { account_id: accounts.selectedId, ...draft },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWatch(id: number, draft: WatchDraft): Promise<Watch> {
|
||||||
|
return request<Watch>(`/watches/${id}`, { body: draft, method: "PUT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWatch(id: number): Promise<void> {
|
||||||
|
return request<void>(`/watches/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAlerts(
|
||||||
|
options: { before?: number; limit?: number; seen?: boolean } = {}
|
||||||
|
): Promise<AlertPage> {
|
||||||
|
return request<AlertPage>("/alerts", { account: true, query: options });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unseenAlerts(): Promise<number> {
|
||||||
|
const { unseen } = await request<{ unseen: number }>("/alerts/unseen", {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
return unseen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAlertSeen(id: number): Promise<void> {
|
||||||
|
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllAlertsSeen(upTo?: number): Promise<{ marked: number }> {
|
||||||
|
return request<{ marked: number }>("/alerts/seen", {
|
||||||
|
body: { account_id: accounts.selectedId, up_to: upTo ?? null },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWebhooks(): Promise<Webhook[]> {
|
||||||
|
return request<Webhook[]>("/webhooks", { account: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebhook(draft: WebhookDraft): Promise<Webhook> {
|
||||||
|
return request<Webhook>("/webhooks", {
|
||||||
|
body: { account_id: accounts.selectedId, ...draft },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWebhook(
|
||||||
|
id: number,
|
||||||
|
draft: WebhookDraft
|
||||||
|
): Promise<Webhook> {
|
||||||
|
return request<Webhook>(`/webhooks/${id}`, { body: draft, method: "PUT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebhook(id: number): Promise<void> {
|
||||||
|
return request<void>(`/webhooks/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateWebhookSecret(id: number): Promise<Webhook> {
|
||||||
|
return request<Webhook>(`/webhooks/${id}/secret`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testWebhook(id: number): Promise<Delivery> {
|
||||||
|
return request<Delivery>(`/webhooks/${id}/test`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDeliveries(id: number): Promise<Delivery[]> {
|
||||||
|
return request<Delivery[]>(`/webhooks/${id}/deliveries`);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.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 { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = $derived({
|
const titles: Record<RightPanel, string> = $derived({
|
||||||
alerts: "Алерты",
|
alerts: t("settings.alerts"),
|
||||||
annotations: "Заметки",
|
annotations: "Заметки",
|
||||||
bots: "Боты",
|
bots: "Боты",
|
||||||
callbacks: "Callback-данные",
|
callbacks: "Callback-данные",
|
||||||
@@ -46,12 +47,14 @@
|
|||||||
stories: "Сторис",
|
stories: "Сторис",
|
||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
versions: "Версии",
|
versions: "Версии",
|
||||||
watches: "Отслеживания",
|
watches: t("settings.watches"),
|
||||||
|
webhooks: t("watches.webhooks"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
"storage-chat": "storage",
|
"storage-chat": "storage",
|
||||||
"storage-trash": "storage",
|
"storage-trash": "storage",
|
||||||
|
webhooks: "watches",
|
||||||
};
|
};
|
||||||
|
|
||||||
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
||||||
@@ -118,7 +121,13 @@
|
|||||||
{:else if ui.rightPanel === "stories-all"}
|
{:else if ui.rightPanel === "stories-all"}
|
||||||
<AllStoriesArchive />
|
<AllStoriesArchive />
|
||||||
{:else if ui.rightPanel === "watches"}
|
{:else if ui.rightPanel === "watches"}
|
||||||
|
{#key accounts.selectedId}
|
||||||
<WatchesPanel />
|
<WatchesPanel />
|
||||||
|
{/key}
|
||||||
|
{:else if ui.rightPanel === "webhooks"}
|
||||||
|
{#key accounts.selectedId}
|
||||||
|
<WebhooksPanel />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "alerts"}
|
{:else if ui.rightPanel === "alerts"}
|
||||||
<AlertsPanel />
|
<AlertsPanel />
|
||||||
{:else if ui.rightPanel === "annotations"}
|
{: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">
|
<script lang="ts">
|
||||||
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js";
|
import { onMount, untrack } from "svelte";
|
||||||
import type { Alert } from "#lib/api/types.js";
|
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 { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import Spinner from "../ui/Spinner.svelte";
|
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
||||||
import AlertList from "./AlertList.svelte";
|
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 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) {
|
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;
|
loading = true;
|
||||||
|
} else {
|
||||||
|
loadingMore = true;
|
||||||
|
}
|
||||||
try {
|
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 {
|
} finally {
|
||||||
|
if (mine === token) {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
load(accounts.selectedId, unseenOnly);
|
if (accounts.selectedId !== null) {
|
||||||
|
load(filter);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function seen(alert: Alert) {
|
onMount(() => {
|
||||||
await markAlertSeen(alert.id);
|
writeParam(filter);
|
||||||
if (unseenOnly) {
|
const stop = alerts.onAlert((alert) => {
|
||||||
alerts = alerts.filter((a) => a.id !== alert.id);
|
if (!items.some((item) => item.id === alert.id)) {
|
||||||
} else {
|
items = [alert, ...items];
|
||||||
alerts = alerts.map((a) =>
|
|
||||||
a.id === alert.id ? { ...a, seen: true } : a
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="tabs">
|
||||||
<label class="toggle">
|
<button
|
||||||
<input type="checkbox" bind:checked={unseenOnly}>
|
aria-pressed={filter === "all"}
|
||||||
<span>Только непрочитанные</span>
|
class="tab"
|
||||||
</label>
|
onclick={() => setFilter("all")}
|
||||||
|
type="button"
|
||||||
{#if loading}
|
class:on={filter === "all"}
|
||||||
<Spinner />
|
>
|
||||||
{:else if alerts.length === 0}
|
{t("alerts.all")}
|
||||||
<p class="empty">Алертов нет.</p>
|
</button>
|
||||||
{:else}
|
<button
|
||||||
<AlertList {alerts} onseen={seen} />
|
aria-pressed={filter === "unseen"}
|
||||||
{/if}
|
class="tab"
|
||||||
|
onclick={() => setFilter("unseen")}
|
||||||
|
type="button"
|
||||||
|
class:on={filter === "unseen"}
|
||||||
|
>
|
||||||
|
{t("alerts.unseen")}
|
||||||
|
</button>
|
||||||
</div>
|
</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">
|
<style lang="scss">
|
||||||
.panel-content {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-shrink: 0;
|
||||||
gap: 1rem;
|
border-bottom: 1px solid var(--color-borders);
|
||||||
padding: 1rem;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.toggle {
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
:global(.icon) {
|
||||||
|
color: var(--color-icon-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
align-items: center;
|
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);
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.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;
|
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>
|
</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">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
import {
|
import {
|
||||||
createWatch,
|
|
||||||
deleteWatch,
|
deleteWatch,
|
||||||
listWatches,
|
listWatches,
|
||||||
|
listWebhooks,
|
||||||
updateWatch,
|
updateWatch,
|
||||||
} from "#lib/api/endpoints.js";
|
type Watch,
|
||||||
import type { Watch } from "#lib/api/types.js";
|
} from "#lib/api/watches.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||||
import Spinner from "../ui/Spinner.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import WatchEditor from "./WatchEditor.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import WatchList from "./WatchList.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 watches = $state<Watch[]>([]);
|
||||||
|
let hookCount = $state(0);
|
||||||
let loading = $state(true);
|
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) {
|
async function load() {
|
||||||
loading = true;
|
token += 1;
|
||||||
|
const current = token;
|
||||||
try {
|
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 {
|
} finally {
|
||||||
|
if (current === token) {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
load(accounts.selectedId);
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
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) {
|
async function toggle(watch: Watch) {
|
||||||
const updated = await updateWatch(watch.id, watch.params, !watch.enabled);
|
problem = null;
|
||||||
watches = watches.map((w) => (w.id === updated.id ? updated : w));
|
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) {
|
async function remove(watch: Watch) {
|
||||||
|
problem = null;
|
||||||
|
try {
|
||||||
await deleteWatch(watch.id);
|
await deleteWatch(watch.id);
|
||||||
watches = watches.filter((w) => w.id !== 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>
|
</script>
|
||||||
|
|
||||||
<div class="panel-content">
|
{#snippet lead(
|
||||||
<WatchEditor onsave={add} {saving} />
|
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}
|
{#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}
|
{: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}
|
{: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}
|
||||||
|
{#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>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.25rem;
|
||||||
padding: 1rem;
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--color-text-secondary);
|
padding: 2.5rem 1rem 2rem;
|
||||||
text-align: center;
|
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>
|
</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>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import type {
|
||||||
|
AlertEvent,
|
||||||
|
AlertItem,
|
||||||
|
ChatRef,
|
||||||
|
Delivery,
|
||||||
|
PersonRef,
|
||||||
|
StoredParams,
|
||||||
|
WatchKind,
|
||||||
|
} from "#lib/api/watches.js";
|
||||||
|
import { formatTime } from "#lib/format/datetime.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
|
export const KIND_ICONS: Record<WatchKind, string> = {
|
||||||
|
keyword: "search",
|
||||||
|
message_deleted: "delete",
|
||||||
|
message_edited: "edit",
|
||||||
|
new_message: "message",
|
||||||
|
peer_online: "user-online",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_ICONS: Record<AlertEvent, string> = {
|
||||||
|
delete: "delete",
|
||||||
|
edit: "edit",
|
||||||
|
message: "message",
|
||||||
|
online: "user-online",
|
||||||
|
transcript: "transcribe",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WATCH_KINDS: WatchKind[] = [
|
||||||
|
"keyword",
|
||||||
|
"new_message",
|
||||||
|
"message_deleted",
|
||||||
|
"message_edited",
|
||||||
|
"peer_online",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Picked {
|
||||||
|
hasAvatar: boolean;
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Described {
|
||||||
|
chat: ChatRef | null;
|
||||||
|
kind: WatchKind;
|
||||||
|
params: StoredParams;
|
||||||
|
person: PersonRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const HOURS_AS_DAYS = 48;
|
||||||
|
const WHITESPACE = /\s+/g;
|
||||||
|
|
||||||
|
export function chatTitle(chat: ChatRef | null, id?: number): string {
|
||||||
|
if (chat?.title) {
|
||||||
|
return chat.title;
|
||||||
|
}
|
||||||
|
return t("watches.unknownChat", { id: chat?.id ?? id ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personName(person: PersonRef | null, id?: number): string {
|
||||||
|
if (person?.name) {
|
||||||
|
return person.name;
|
||||||
|
}
|
||||||
|
return t("watches.unknownPerson", { id: person?.id ?? id ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternOf(params: StoredParams): string {
|
||||||
|
return params.pattern ?? params.keyword ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function what(params: StoredParams): string {
|
||||||
|
const key = params.regex ? "watches.describe.regex" : "watches.describe.word";
|
||||||
|
return t(key, { pattern: patternOf(params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeKeyword(watch: Described): string {
|
||||||
|
const { params } = watch;
|
||||||
|
const values = {
|
||||||
|
chat: chatTitle(watch.chat, params.chat_id),
|
||||||
|
person: personName(watch.person, params.sender_id),
|
||||||
|
what: what(params),
|
||||||
|
};
|
||||||
|
if (params.chat_id !== undefined && params.sender_id !== undefined) {
|
||||||
|
return t("watches.describe.keywordFromIn", values);
|
||||||
|
}
|
||||||
|
if (params.chat_id !== undefined) {
|
||||||
|
return t("watches.describe.keywordIn", values);
|
||||||
|
}
|
||||||
|
if (params.sender_id !== undefined) {
|
||||||
|
return t("watches.describe.keywordFrom", values);
|
||||||
|
}
|
||||||
|
return t("watches.describe.keyword", values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeNew(watch: Described): string {
|
||||||
|
const { params } = watch;
|
||||||
|
const values = {
|
||||||
|
chat: chatTitle(watch.chat, params.chat_id),
|
||||||
|
person: personName(watch.person, params.sender_id),
|
||||||
|
};
|
||||||
|
if (params.chat_id !== undefined && params.sender_id !== undefined) {
|
||||||
|
return t("watches.describe.newFromIn", values);
|
||||||
|
}
|
||||||
|
if (params.chat_id !== undefined) {
|
||||||
|
return t("watches.describe.newIn", values);
|
||||||
|
}
|
||||||
|
return t("watches.describe.newFrom", values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeWatch(watch: Described): string {
|
||||||
|
const { params } = watch;
|
||||||
|
const chat = { chat: chatTitle(watch.chat, params.chat_id) };
|
||||||
|
switch (watch.kind) {
|
||||||
|
case "keyword":
|
||||||
|
return describeKeyword(watch);
|
||||||
|
case "new_message":
|
||||||
|
return describeNew(watch);
|
||||||
|
case "message_deleted":
|
||||||
|
return params.chat_id === undefined
|
||||||
|
? t("watches.describe.deletedDm")
|
||||||
|
: t("watches.describe.deletedIn", chat);
|
||||||
|
case "message_edited":
|
||||||
|
return params.chat_id === undefined
|
||||||
|
? t("watches.describe.editedDm")
|
||||||
|
: t("watches.describe.editedIn", chat);
|
||||||
|
default:
|
||||||
|
return t("watches.describe.online", {
|
||||||
|
person: personName(watch.person, params.peer_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitFormat(unit: "minute" | "hour" | "day", value: number): string {
|
||||||
|
return new Intl.NumberFormat(locale.tag, {
|
||||||
|
style: "unit",
|
||||||
|
unit,
|
||||||
|
unitDisplay: "short",
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function awayFor(since: string, until: string): string {
|
||||||
|
const gap = Math.max(
|
||||||
|
0,
|
||||||
|
new Date(until).getTime() - new Date(since).getTime()
|
||||||
|
);
|
||||||
|
if (gap < HOUR) {
|
||||||
|
return unitFormat("minute", Math.max(1, Math.round(gap / MINUTE)));
|
||||||
|
}
|
||||||
|
if (gap < HOURS_AS_DAYS * HOUR) {
|
||||||
|
return unitFormat("hour", Math.round(gap / HOUR));
|
||||||
|
}
|
||||||
|
return unitFormat("day", Math.round(gap / DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertTitle(alert: AlertItem): string {
|
||||||
|
if (alert.chat) {
|
||||||
|
return chatTitle(alert.chat);
|
||||||
|
}
|
||||||
|
return personName(alert.peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertLabel(alert: AlertItem): string {
|
||||||
|
if (alert.watch.name) {
|
||||||
|
return alert.watch.name;
|
||||||
|
}
|
||||||
|
if (alert.watch.kind === "keyword") {
|
||||||
|
return what(alert.watch.params);
|
||||||
|
}
|
||||||
|
return t(`alerts.event.${alert.event}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertPreview(alert: AlertItem): string {
|
||||||
|
if (alert.event === "online") {
|
||||||
|
return alert.away_since
|
||||||
|
? t("alerts.onlineAfter", { away: awayFor(alert.away_since, alert.ts) })
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
const { message } = alert;
|
||||||
|
const text = message?.text?.replace(WHITESPACE, " ") || t("alerts.noText");
|
||||||
|
const sender = message?.sender;
|
||||||
|
if (
|
||||||
|
!sender ||
|
||||||
|
sender.id === alert.chat?.id ||
|
||||||
|
alert.chat?.kind === "private"
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${personName(sender)}: ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertLine(alert: AlertItem): string {
|
||||||
|
const preview = alertPreview(alert);
|
||||||
|
return preview ? `${alertLabel(alert)}: ${preview}` : alertLabel(alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertAvatar(alert: AlertItem): {
|
||||||
|
hasAvatar: boolean;
|
||||||
|
id: number;
|
||||||
|
kind: "chat" | "peer";
|
||||||
|
name: string;
|
||||||
|
} {
|
||||||
|
if (alert.chat) {
|
||||||
|
return {
|
||||||
|
hasAvatar: alert.chat.has_avatar,
|
||||||
|
id: alert.chat.id,
|
||||||
|
kind: alert.chat.id > 0 ? "peer" : "chat",
|
||||||
|
name: chatTitle(alert.chat),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hasAvatar: alert.peer?.has_avatar ?? false,
|
||||||
|
id: alert.peer?.id ?? 0,
|
||||||
|
kind: "peer",
|
||||||
|
name: personName(alert.peer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryReason(delivery: Delivery): string {
|
||||||
|
return delivery.response_code === null
|
||||||
|
? (delivery.last_error ?? "")
|
||||||
|
: String(delivery.response_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveryStatus(delivery: Delivery): string {
|
||||||
|
const waiting =
|
||||||
|
delivery.status === "sending" ||
|
||||||
|
(delivery.status === "pending" && delivery.attempts === 0);
|
||||||
|
if (waiting) {
|
||||||
|
return t("webhooks.status.sending");
|
||||||
|
}
|
||||||
|
const head =
|
||||||
|
delivery.status === "pending"
|
||||||
|
? t("webhooks.status.pending", {
|
||||||
|
time: formatTime(delivery.next_attempt_at),
|
||||||
|
})
|
||||||
|
: t(`webhooks.status.${delivery.status}`);
|
||||||
|
const reason = deliveryReason(delivery);
|
||||||
|
return reason ? `${head} · ${reason}` : head;
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { account } from "#lib/i18n/messages/account.js";
|
import { account } from "#lib/i18n/messages/account.js";
|
||||||
|
import { alerts } from "#lib/i18n/messages/alerts.js";
|
||||||
import { common } from "#lib/i18n/messages/common.js";
|
import { common } from "#lib/i18n/messages/common.js";
|
||||||
import { menu } from "#lib/i18n/messages/menu.js";
|
import { menu } from "#lib/i18n/messages/menu.js";
|
||||||
import { read } from "#lib/i18n/messages/read.js";
|
import { read } from "#lib/i18n/messages/read.js";
|
||||||
import { settings } from "#lib/i18n/messages/settings.js";
|
import { settings } from "#lib/i18n/messages/settings.js";
|
||||||
|
import { watches } from "#lib/i18n/messages/watches.js";
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
...account,
|
...account,
|
||||||
|
...alerts,
|
||||||
...common,
|
...common,
|
||||||
...menu,
|
...menu,
|
||||||
...read,
|
...read,
|
||||||
...settings,
|
...settings,
|
||||||
|
...watches,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageKey = keyof typeof messages;
|
export type MessageKey = keyof typeof messages;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const alerts = {
|
||||||
|
"alerts.all": { en: "All", ru: "Все" },
|
||||||
|
"alerts.badge": {
|
||||||
|
en: { one: "{count} unread alert", other: "{count} unread alerts" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} непрочитанных оповещения",
|
||||||
|
many: "{count} непрочитанных оповещений",
|
||||||
|
one: "{count} непрочитанное оповещение",
|
||||||
|
other: "{count} непрочитанных оповещения",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"alerts.empty.description": {
|
||||||
|
en: "They show up here when a watch fires",
|
||||||
|
ru: "Они появятся здесь, когда сработает наблюдение",
|
||||||
|
},
|
||||||
|
"alerts.empty.title": { en: "No alerts", ru: "Оповещений нет" },
|
||||||
|
"alerts.emptyUnseen.title": { en: "All read", ru: "Всё прочитано" },
|
||||||
|
"alerts.event.delete": { en: "Deleted", ru: "Удалено" },
|
||||||
|
"alerts.event.edit": { en: "Edited", ru: "Изменено" },
|
||||||
|
"alerts.event.message": { en: "New", ru: "Новое" },
|
||||||
|
"alerts.event.online": { en: "Online", ru: "В сети" },
|
||||||
|
"alerts.event.transcript": { en: "In a voice message", ru: "В голосовом" },
|
||||||
|
"alerts.loadFailed": {
|
||||||
|
en: "Couldn't load alerts",
|
||||||
|
ru: "Не удалось загрузить оповещения",
|
||||||
|
},
|
||||||
|
"alerts.markAll": { en: "Mark all as read", ru: "Отметить все прочитанными" },
|
||||||
|
"alerts.markSeen": { en: "Mark as read", ru: "Отметить прочитанным" },
|
||||||
|
"alerts.more": { en: "Show more", ru: "Показать ещё" },
|
||||||
|
"alerts.noText": { en: "No text", ru: "Без текста" },
|
||||||
|
"alerts.notify": {
|
||||||
|
en: "Browser notifications",
|
||||||
|
ru: "Уведомления в браузере",
|
||||||
|
},
|
||||||
|
"alerts.notifyDenied": {
|
||||||
|
en: "The browser blocked notifications for this site",
|
||||||
|
ru: "Браузер запретил уведомления для этого сайта",
|
||||||
|
},
|
||||||
|
"alerts.onlineAfter": { en: "after {away} away", ru: "не было {away}" },
|
||||||
|
"alerts.open": { en: "Go to message", ru: "Перейти к сообщению" },
|
||||||
|
"alerts.openChat": { en: "Open chat", ru: "Открыть чат" },
|
||||||
|
"alerts.retry": { en: "Retry", ru: "Повторить" },
|
||||||
|
"alerts.setup": { en: "Set up watches", ru: "Настроить наблюдения" },
|
||||||
|
"alerts.unseen": { en: "Unread", ru: "Непрочитанные" },
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const watches = {
|
||||||
|
"watches.add": { en: "New watch", ru: "Новое наблюдение" },
|
||||||
|
"watches.anyChat": { en: "Any chat", ru: "Любой чат" },
|
||||||
|
"watches.anyDm": { en: "Any private chat", ru: "Любая личка" },
|
||||||
|
"watches.anySender": { en: "Anyone", ru: "Кто угодно" },
|
||||||
|
"watches.chat": { en: "Chat", ru: "Чат" },
|
||||||
|
"watches.choose": { en: "Choose", ru: "Выбрать" },
|
||||||
|
"watches.clear": { en: "Clear", ru: "Убрать" },
|
||||||
|
"watches.delete": { en: "Delete", ru: "Удалить" },
|
||||||
|
"watches.deleteConfirm": {
|
||||||
|
en: "Delete the watch with its alerts?",
|
||||||
|
ru: "Удалить наблюдение вместе с оповещениями?",
|
||||||
|
},
|
||||||
|
"watches.describe.deletedDm": {
|
||||||
|
en: "Deleted messages in private chats",
|
||||||
|
ru: "Удалённые сообщения в личке",
|
||||||
|
},
|
||||||
|
"watches.describe.deletedIn": {
|
||||||
|
en: "Deleted messages in {chat}",
|
||||||
|
ru: "Удалённые сообщения в чате «{chat}»",
|
||||||
|
},
|
||||||
|
"watches.describe.editedDm": {
|
||||||
|
en: "Edited messages in private chats",
|
||||||
|
ru: "Изменённые сообщения в личке",
|
||||||
|
},
|
||||||
|
"watches.describe.editedIn": {
|
||||||
|
en: "Edited messages in {chat}",
|
||||||
|
ru: "Изменённые сообщения в чате «{chat}»",
|
||||||
|
},
|
||||||
|
"watches.describe.keyword": {
|
||||||
|
en: "{what} in any chat",
|
||||||
|
ru: "{what} в любом чате",
|
||||||
|
},
|
||||||
|
"watches.describe.keywordFrom": {
|
||||||
|
en: "{what} from {person}",
|
||||||
|
ru: "{what}, отправитель {person}",
|
||||||
|
},
|
||||||
|
"watches.describe.keywordFromIn": {
|
||||||
|
en: "{what} from {person} in {chat}",
|
||||||
|
ru: "{what} в чате «{chat}», отправитель {person}",
|
||||||
|
},
|
||||||
|
"watches.describe.keywordIn": {
|
||||||
|
en: "{what} in {chat}",
|
||||||
|
ru: "{what} в чате «{chat}»",
|
||||||
|
},
|
||||||
|
"watches.describe.newFrom": {
|
||||||
|
en: "New messages from {person}",
|
||||||
|
ru: "Новые сообщения, отправитель {person}",
|
||||||
|
},
|
||||||
|
"watches.describe.newFromIn": {
|
||||||
|
en: "New messages from {person} in {chat}",
|
||||||
|
ru: "Новые сообщения в чате «{chat}», отправитель {person}",
|
||||||
|
},
|
||||||
|
"watches.describe.newIn": {
|
||||||
|
en: "New messages in {chat}",
|
||||||
|
ru: "Новые сообщения в чате «{chat}»",
|
||||||
|
},
|
||||||
|
"watches.describe.online": {
|
||||||
|
en: "{person} is online",
|
||||||
|
ru: "{person} в сети",
|
||||||
|
},
|
||||||
|
"watches.describe.regex": {
|
||||||
|
en: "Pattern “{pattern}”",
|
||||||
|
ru: "Шаблон «{pattern}»",
|
||||||
|
},
|
||||||
|
"watches.describe.word": { en: "Word “{pattern}”", ru: "Слово «{pattern}»" },
|
||||||
|
"watches.disable": { en: "Turn off", ru: "Выключить" },
|
||||||
|
"watches.edit": { en: "Edit", ru: "Изменить" },
|
||||||
|
"watches.empty.description": {
|
||||||
|
en: "A watch looks for a word, a person or deleted messages and raises an alert",
|
||||||
|
ru: "Наблюдение ищет слово, человека или удалённые сообщения и присылает оповещение",
|
||||||
|
},
|
||||||
|
"watches.empty.title": { en: "No watches yet", ru: "Наблюдений пока нет" },
|
||||||
|
"watches.enable": { en: "Turn on", ru: "Включить" },
|
||||||
|
"watches.error.action": {
|
||||||
|
en: "That didn't work, try again",
|
||||||
|
ru: "Не получилось, попробуйте ещё раз",
|
||||||
|
},
|
||||||
|
"watches.error.badRegex": {
|
||||||
|
en: "Can't read the expression: {detail}",
|
||||||
|
ru: "Не получилось разобрать выражение: {detail}",
|
||||||
|
},
|
||||||
|
"watches.error.failed": {
|
||||||
|
en: "Couldn't save: {detail}",
|
||||||
|
ru: "Не удалось сохранить: {detail}",
|
||||||
|
},
|
||||||
|
"watches.error.needPattern": {
|
||||||
|
en: "Enter a word or a phrase",
|
||||||
|
ru: "Впишите слово или фразу",
|
||||||
|
},
|
||||||
|
"watches.error.needPerson": {
|
||||||
|
en: "Choose a person",
|
||||||
|
ru: "Выберите человека",
|
||||||
|
},
|
||||||
|
"watches.error.needScope": {
|
||||||
|
en: "Choose a chat or a sender",
|
||||||
|
ru: "Выберите чат или отправителя",
|
||||||
|
},
|
||||||
|
"watches.error.slowRegex": {
|
||||||
|
en: "A repeat inside a repeat, like (a+)+, can hang. Make the expression simpler",
|
||||||
|
ru: "Повтор внутри повтора, вроде (a+)+, может зависнуть. Упростите выражение",
|
||||||
|
},
|
||||||
|
"watches.expression": { en: "Expression", ru: "Выражение" },
|
||||||
|
"watches.fired": {
|
||||||
|
en: {
|
||||||
|
one: "{count} alert, last {date}",
|
||||||
|
other: "{count} alerts, last {date}",
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
few: "{count} оповещения, последнее {date}",
|
||||||
|
many: "{count} оповещений, последнее {date}",
|
||||||
|
one: "{count} оповещение, последнее {date}",
|
||||||
|
other: "{count} оповещения, последнее {date}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"watches.kind": { en: "What to watch", ru: "Что ищем" },
|
||||||
|
"watches.kind.keyword": { en: "A word or a pattern", ru: "Слово или шаблон" },
|
||||||
|
"watches.kind.message_deleted": {
|
||||||
|
en: "Deleted messages",
|
||||||
|
ru: "Удалённые сообщения",
|
||||||
|
},
|
||||||
|
"watches.kind.message_edited": {
|
||||||
|
en: "Edited messages",
|
||||||
|
ru: "Изменённые сообщения",
|
||||||
|
},
|
||||||
|
"watches.kind.new_message": { en: "New messages", ru: "Новые сообщения" },
|
||||||
|
"watches.kind.peer_online": { en: "Coming online", ru: "Появление в сети" },
|
||||||
|
"watches.loadFailed": {
|
||||||
|
en: "Couldn't load watches",
|
||||||
|
ru: "Не удалось загрузить наблюдения",
|
||||||
|
},
|
||||||
|
"watches.name": { en: "Name, optional", ru: "Название, если нужно" },
|
||||||
|
"watches.never": { en: "Hasn't fired yet", ru: "Ещё не срабатывало" },
|
||||||
|
"watches.nothing": { en: "Nothing found", ru: "Ничего не найдено" },
|
||||||
|
"watches.off": { en: "Off", ru: "Выключено" },
|
||||||
|
"watches.pattern": { en: "Word or phrase", ru: "Слово или фраза" },
|
||||||
|
"watches.person": { en: "Person", ru: "Человек" },
|
||||||
|
"watches.regex": { en: "Regular expression", ru: "Регулярное выражение" },
|
||||||
|
"watches.retry": { en: "Retry", ru: "Повторить" },
|
||||||
|
"watches.search": { en: "Search chats", ru: "Поиск чата" },
|
||||||
|
"watches.searchPeople": { en: "Search people", ru: "Поиск человека" },
|
||||||
|
"watches.sender": { en: "Sender", ru: "Отправитель" },
|
||||||
|
"watches.unknownChat": { en: "Chat {id}", ru: "Чат {id}" },
|
||||||
|
"watches.unknownPerson": { en: "User {id}", ru: "Пользователь {id}" },
|
||||||
|
"watches.webhooks": { en: "Webhooks", ru: "Вебхуки" },
|
||||||
|
"webhooks.add": { en: "Add a webhook", ru: "Добавить вебхук" },
|
||||||
|
"webhooks.allWatches": { en: "All watches", ru: "Все наблюдения" },
|
||||||
|
"webhooks.attempts": {
|
||||||
|
en: { one: "{count} attempt", other: "{count} attempts" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} попытки",
|
||||||
|
many: "{count} попыток",
|
||||||
|
one: "{count} попытка",
|
||||||
|
other: "{count} попытки",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"webhooks.badUrl": {
|
||||||
|
en: "Needs an http or https address",
|
||||||
|
ru: "Нужен адрес http или https",
|
||||||
|
},
|
||||||
|
"webhooks.copied": { en: "Copied", ru: "Скопировано" },
|
||||||
|
"webhooks.copy": { en: "Copy", ru: "Скопировать" },
|
||||||
|
"webhooks.deleteConfirm": {
|
||||||
|
en: "Delete the webhook?",
|
||||||
|
ru: "Удалить вебхук?",
|
||||||
|
},
|
||||||
|
"webhooks.empty.description": {
|
||||||
|
en: "Every alert goes out as a signed POST to your address",
|
||||||
|
ru: "Каждое оповещение уйдёт POST-запросом с подписью на ваш адрес",
|
||||||
|
},
|
||||||
|
"webhooks.empty.title": { en: "No webhooks", ru: "Вебхуков нет" },
|
||||||
|
"webhooks.enabled": { en: "Send alerts", ru: "Отправлять оповещения" },
|
||||||
|
"webhooks.event.alert": { en: "Alert", ru: "Оповещение" },
|
||||||
|
"webhooks.event.test": { en: "Test", ru: "Проверка" },
|
||||||
|
"webhooks.filter": { en: "Which watches", ru: "Какие наблюдения" },
|
||||||
|
"webhooks.log": { en: "Recent deliveries", ru: "Последние доставки" },
|
||||||
|
"webhooks.logEmpty": {
|
||||||
|
en: "Nothing sent yet",
|
||||||
|
ru: "Пока ничего не отправлено",
|
||||||
|
},
|
||||||
|
"webhooks.never": { en: "Nothing sent yet", ru: "Ещё не отправлялся" },
|
||||||
|
"webhooks.off": { en: "Off", ru: "Выключен" },
|
||||||
|
"webhooks.rotate": { en: "New secret", ru: "Новый секрет" },
|
||||||
|
"webhooks.secret": { en: "Signing secret", ru: "Секрет подписи" },
|
||||||
|
"webhooks.signature": {
|
||||||
|
en: "Header X-Beavergram-Signature: sha256=HMAC of the body",
|
||||||
|
ru: "Заголовок X-Beavergram-Signature: sha256=HMAC тела",
|
||||||
|
},
|
||||||
|
"webhooks.status.delivered": { en: "Delivered", ru: "Доставлено" },
|
||||||
|
"webhooks.status.failed": { en: "Not delivered", ru: "Не доставлено" },
|
||||||
|
"webhooks.status.pending": { en: "Retry at {time}", ru: "Повтор в {time}" },
|
||||||
|
"webhooks.status.sending": { en: "Sending", ru: "Отправляется" },
|
||||||
|
"webhooks.test": { en: "Send a test", ru: "Проверить" },
|
||||||
|
"webhooks.testFailed": {
|
||||||
|
en: "Not delivered: {error}",
|
||||||
|
ru: "Не доставлено: {error}",
|
||||||
|
},
|
||||||
|
"webhooks.testing": { en: "Sending…", ru: "Отправляю…" },
|
||||||
|
"webhooks.testOk": {
|
||||||
|
en: "Delivered, response {code}",
|
||||||
|
ru: "Доставлено, ответ {code}",
|
||||||
|
},
|
||||||
|
"webhooks.url": { en: "Address", ru: "Адрес" },
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
type AlertItem,
|
||||||
|
type LiveAlertEvent,
|
||||||
|
unseenAlerts,
|
||||||
|
} from "#lib/api/watches.js";
|
||||||
|
import { alertLine, alertTitle } from "#lib/format/watches.js";
|
||||||
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
|
import { browser } from "$app/env";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const NOTIFY_KEY = "bg.alerts.notify";
|
||||||
|
|
||||||
|
export const ALERTS_PARAM = "alerts";
|
||||||
|
|
||||||
|
type Listener = (alert: AlertItem) => void;
|
||||||
|
|
||||||
|
function notifySupported(): boolean {
|
||||||
|
return browser && "Notification" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAlerts() {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
let unseen = $state(0);
|
||||||
|
let notify = $state(
|
||||||
|
notifySupported() &&
|
||||||
|
localStorage.getItem(NOTIFY_KEY) === "1" &&
|
||||||
|
Notification.permission === "granted"
|
||||||
|
);
|
||||||
|
let account: number | null = null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (account === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = account;
|
||||||
|
const count = await unseenAlerts().catch(() => null);
|
||||||
|
if (count !== null && current === account) {
|
||||||
|
unseen = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function popUp(alert: AlertItem) {
|
||||||
|
if (!(notify && document.hidden)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const popup = new Notification(alertTitle(alert), {
|
||||||
|
body: alertLine(alert),
|
||||||
|
tag: `bg-alert-${alert.id}`,
|
||||||
|
});
|
||||||
|
popup.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
goto(alert.path);
|
||||||
|
popup.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(event: LiveAlertEvent) {
|
||||||
|
({ unseen } = event);
|
||||||
|
if (event.alert === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(event.alert);
|
||||||
|
}
|
||||||
|
popUp(event.alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.subscribe((event) => {
|
||||||
|
if (event.type === "alert") {
|
||||||
|
apply(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
events.onReconnect(() => {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get notify(): boolean {
|
||||||
|
return notify;
|
||||||
|
},
|
||||||
|
get notifySupported(): boolean {
|
||||||
|
return notifySupported();
|
||||||
|
},
|
||||||
|
onAlert(listener: Listener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
read(count = 1) {
|
||||||
|
unseen = Math.max(0, unseen - count);
|
||||||
|
},
|
||||||
|
readAll() {
|
||||||
|
unseen = 0;
|
||||||
|
},
|
||||||
|
async setNotify(on: boolean): Promise<boolean> {
|
||||||
|
if (!notifySupported()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const allowed =
|
||||||
|
on &&
|
||||||
|
(Notification.permission === "granted" ||
|
||||||
|
(await Notification.requestPermission()) === "granted");
|
||||||
|
notify = allowed;
|
||||||
|
localStorage.setItem(NOTIFY_KEY, allowed ? "1" : "0");
|
||||||
|
return allowed === on;
|
||||||
|
},
|
||||||
|
track(accountId: number | null) {
|
||||||
|
if (accountId === account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
account = accountId;
|
||||||
|
unseen = 0;
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
get unseen(): number {
|
||||||
|
return unseen;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alerts = createAlerts();
|
||||||
@@ -13,6 +13,7 @@ export type RightPanel =
|
|||||||
| "stories-all"
|
| "stories-all"
|
||||||
| "policy"
|
| "policy"
|
||||||
| "watches"
|
| "watches"
|
||||||
|
| "webhooks"
|
||||||
| "alerts"
|
| "alerts"
|
||||||
| "shares"
|
| "shares"
|
||||||
| "bots"
|
| "bots"
|
||||||
|
|||||||
Reference in new issue
Block a user