feat: deferred dispatch, release-check provider, settings polish

- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
This commit is contained in:
2026-05-12 02:58:07 +03:00
parent bb5afcc222
commit ba199f24bd
47 changed files with 5627 additions and 290 deletions
+40
View File
@@ -377,6 +377,46 @@ button:focus-visible, a:focus-visible {
.stagger-children > * {
animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
}
/* === List stack — used by list pages (providers, trackers, configs, etc.) ===
Full-bleed rows that stretch to the main column width. Pair with .list-row
inside each Card for the 3-zone layout (identity · meta-strip · actions). */
.list-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.list-row {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.list-row__identity {
min-width: 0;
flex: 0 0 auto;
max-width: 28rem;
}
@media (max-width: 1023px) {
.list-row__identity { flex: 1 1 auto; }
}
.list-row__actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* Secondary text under the name — visible only when meta-strip is hidden
(i.e. on narrow screens). On lg+ the meta-strip takes over. */
.list-row__secondary {
display: block;
}
@media (min-width: 1024px) {
.list-row__secondary { display: none; }
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
@@ -12,6 +12,13 @@
}
let { event, onclose }: Props = $props();
// Retain the last non-null event so the modal body stays populated
// while the close transition plays after the parent clears `event`.
let displayEvent = $state<EventLog | null>(null);
$effect(() => {
if (event) displayEvent = event;
});
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
@@ -21,6 +28,44 @@
}
}
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
*
* Used by the deferred-dispatch lifecycle banner to render
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
* integer that the user has to mentally divide. Keeps two units so
* the magnitude reads correctly across hours-long quiet windows
* without becoming noisy for short ones. */
function humanDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours ? `${days}d ${remHours}h` : `${days}d`;
}
/** Render an absolute ISO timestamp as a future-relative string.
*
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
* deferred-until banner shouldn't show a relative offset once the
* window has already ended (a follow-up event_log row marks delivery).
*/
function timeFromNow(iso: string | undefined): string {
if (!iso) return '';
try {
const target = new Date(iso).getTime();
const diff = Math.floor((target - Date.now()) / 1000);
if (diff <= 0) return '';
return humanDuration(diff);
} catch {
return '';
}
}
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
if (!issuer) return '';
if (issuer.username) return '@' + issuer.username;
@@ -41,47 +86,130 @@
goto(path);
}
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuer = $derived(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuerText = $derived(issuerLabel(issuer));
const isCommand = $derived(event?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
const detailsJson = $derived.by(() => {
if (!event?.details) return '';
if (!displayEvent?.details) return '';
try {
return JSON.stringify(event.details, null, 2);
return JSON.stringify(displayEvent.details, null, 2);
} catch {
return String(event.details);
return String(displayEvent.details);
}
});
</script>
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
{#if event}
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
{#if displayEvent}
<div class="event-detail">
<!-- Subject + verb -->
<div class="hero-row">
<MdiIcon name="mdiBell" size={18} />
<div>
<div class="hero-subject">{event.collection_name || event.event_type}</div>
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
<div class="hero-meta">
<span class="event-type">{event.event_type}</span>
<span class="event-type">{displayEvent.event_type}</span>
<span class="dot">·</span>
<span>{fmtDateTime(event.created_at)}</span>
<span>{fmtDateTime(displayEvent.created_at)}</span>
</div>
</div>
</div>
<!-- Dispatch lifecycle (only when the event went through the
quiet-hours defer path). Rendered ABOVE the provenance grid
because timing of delivery is more interesting than the
bot/tracker names when the event is held back. -->
{#if displayEvent.details?.dispatch_status === 'deferred'}
<section class="lifecycle lifecycle--deferred">
<MdiIcon name="mdiPauseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
<div class="lifecycle-detail">
{t('events.lifecycle.heldUntil')}
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
{#if timeFromNow(displayEvent.details.deferred_until)}
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
{/if}
</div>
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
<section class="lifecycle lifecycle--late">
<MdiIcon name="mdiClockCheckOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
{#if displayEvent.details.deferred_for_seconds != null}
<div class="lifecycle-detail">
{t('events.lifecycle.heldFor')}
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiCloseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiAlertCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiVolumeOff" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
</div>
</section>
{/if}
<!-- Provenance grid -->
<dl class="provenance">
{#if event.bot_name}
{#if displayEvent.bot_name}
<dt>{t('events.bot')}</dt>
<dd>{event.bot_name}</dd>
<dd>{displayEvent.bot_name}</dd>
{/if}
{#if event.collection_id && isCommand}
{#if displayEvent.collection_id && isCommand}
<dt>{t('events.chat')}</dt>
<dd class="font-mono">{event.collection_id}</dd>
<dd class="font-mono">{displayEvent.collection_id}</dd>
{/if}
{#if issuerText}
<dt>{t('events.issuer')}</dt>
@@ -90,56 +218,56 @@
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
</dd>
{/if}
{#if event.command_tracker_name}
{#if displayEvent.command_tracker_name}
<dt>{t('events.commandTracker')}</dt>
<dd>{event.command_tracker_name}</dd>
<dd>{displayEvent.command_tracker_name}</dd>
{/if}
{#if event.tracker_name}
{#if displayEvent.tracker_name}
<dt>{t('events.tracker')}</dt>
<dd>{event.tracker_name}</dd>
<dd>{displayEvent.tracker_name}</dd>
{/if}
{#if event.action_name}
{#if displayEvent.action_name}
<dt>{t('events.action')}</dt>
<dd>{event.action_name}</dd>
<dd>{displayEvent.action_name}</dd>
{/if}
{#if event.provider_name}
{#if displayEvent.provider_name}
<dt>{t('events.provider')}</dt>
<dd>{event.provider_name}</dd>
<dd>{displayEvent.provider_name}</dd>
{/if}
{#if event.assets_count > 0}
{#if displayEvent.assets_count > 0}
<dt>{t('events.assetsCount')}</dt>
<dd class="font-mono">{event.assets_count}</dd>
<dd class="font-mono">{displayEvent.assets_count}</dd>
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<div class="actions">
{#if event.provider_id}
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
{#if displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if event.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
{#if displayEvent.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if event.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
{#if displayEvent.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if event.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
{#if displayEvent.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && event.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
{#if !isCommand && !isAction && displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.tracker_id)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
@@ -251,4 +379,71 @@
word-break: break-word;
}
.font-mono { font-family: var(--font-mono); }
/* Dispatch lifecycle banner — appears only when the event took the
* quiet-hours defer path. The three colour variants mirror the dashboard
* badge palette: primary glow for "held", success for "delivered late",
* muted/dim for "dropped" / "failed" / "suppressed".
*/
.lifecycle {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.75rem 0.95rem;
border-radius: 0.7rem;
border: 1px solid var(--color-border);
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.lifecycle-body {
display: flex; flex-direction: column; gap: 0.2rem;
flex: 1; min-width: 0;
}
.lifecycle-title {
font-weight: 600;
color: var(--color-foreground);
}
.lifecycle-detail {
color: var(--color-foreground);
}
.lifecycle-detail b {
font-family: var(--font-mono);
font-weight: 600;
}
.lifecycle-rel {
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-left: 0.25rem;
}
.lifecycle-hint {
color: var(--color-muted-foreground);
font-size: 0.72rem;
}
.lifecycle-reason {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
word-break: break-all;
}
.lifecycle--deferred {
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.lifecycle--deferred :global(svg) {
color: var(--color-primary);
}
.lifecycle--late {
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
}
.lifecycle--late :global(svg) {
color: var(--color-success, #16a34a);
}
.lifecycle--dropped {
opacity: 0.92;
}
.lifecycle--dropped :global(svg) {
color: var(--color-muted-foreground);
}
</style>
@@ -0,0 +1,187 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
export type MetaTone = 'default' | 'mint' | 'sky' | 'coral' | 'citrus' | 'orchid' | 'lavender';
export interface MetaTile {
icon?: string;
label: string;
value?: string;
hint?: string;
tone?: MetaTone;
mono?: boolean;
href?: string;
onclick?: (e: MouseEvent) => void;
copyValue?: string;
}
let { tiles, align = 'start' }: {
tiles: MetaTile[];
align?: 'start' | 'end';
} = $props();
function handleClick(e: MouseEvent, tile: MetaTile) {
if (tile.onclick) {
e.preventDefault();
e.stopPropagation();
tile.onclick(e);
}
}
</script>
<div class="meta-strip" style="justify-content: {align === 'end' ? 'flex-end' : 'flex-start'};">
{#each tiles as tile, i (i)}
{#if tile.href}
<a
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
class:meta-tile--mono={tile.mono}
title={tile.hint}
href={tile.href}
target="_blank"
rel="noopener"
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</a>
{:else if tile.onclick}
<button
type="button"
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
class:meta-tile--mono={tile.mono}
title={tile.hint}
onclick={(e: MouseEvent) => handleClick(e, tile)}
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</button>
{:else}
<div
class="meta-tile meta-tone-{tile.tone || 'default'}"
class:meta-tile--mono={tile.mono}
title={tile.hint}
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</div>
{/if}
{/each}
</div>
<style>
.meta-strip {
display: none;
min-width: 0;
flex: 1 1 auto;
gap: 0.45rem;
align-items: center;
overflow: hidden;
mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
padding: 2px 18px;
}
@media (min-width: 1024px) {
.meta-strip {
display: flex;
}
}
.meta-tile {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: var(--color-glass);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border: 1px solid var(--color-border);
font-size: 0.72rem;
line-height: 1.1;
color: var(--color-muted-foreground);
white-space: nowrap;
flex-shrink: 0;
max-width: 22rem;
min-width: 0;
text-decoration: none;
font-family: inherit;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.meta-tile__icon {
display: inline-flex;
align-items: center;
color: currentColor;
opacity: 0.9;
flex-shrink: 0;
}
.meta-tile__text {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
}
.meta-tile__value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.meta-tile__label {
font-size: 0.72rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.meta-tile--mono .meta-tile__label,
.meta-tile--mono .meta-tile__value {
font-family: var(--font-mono);
letter-spacing: -0.02em;
font-size: 0.7rem;
}
.meta-tile--interactive {
cursor: pointer;
}
.meta-tile--interactive:hover {
border-color: var(--color-rule-strong);
background: var(--color-glass-strong);
transform: translateY(-1px);
}
/* Tone variants — applied to the dot/icon and accent border on hover */
.meta-tone-mint { box-shadow: inset 2px 0 0 var(--color-mint); }
.meta-tone-sky { box-shadow: inset 2px 0 0 var(--color-sky); }
.meta-tone-coral { box-shadow: inset 2px 0 0 var(--color-coral); }
.meta-tone-citrus { box-shadow: inset 2px 0 0 var(--color-citrus); }
.meta-tone-orchid { box-shadow: inset 2px 0 0 var(--color-orchid); }
.meta-tone-lavender { box-shadow: inset 2px 0 0 var(--color-primary); }
.meta-tone-mint .meta-tile__icon { color: var(--color-mint); }
.meta-tone-sky .meta-tile__icon { color: var(--color-sky); }
.meta-tone-coral .meta-tile__icon { color: var(--color-coral); }
.meta-tone-citrus .meta-tile__icon { color: var(--color-citrus); }
.meta-tone-orchid .meta-tile__icon { color: var(--color-orchid); }
.meta-tone-lavender .meta-tile__icon { color: var(--color-primary); }
</style>
+15 -2
View File
@@ -11,14 +11,22 @@
}>();
let visible = $state(false);
let mounted = $state(false);
let panelEl = $state<HTMLDivElement | undefined>();
let previouslyFocused: HTMLElement | null = null;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
const TRANSITION_MS = 250;
$effect(() => {
if (open) {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
previouslyFocused = document.activeElement as HTMLElement | null;
mounted = true;
requestAnimationFrame(() => {
visible = true;
// Focus first focusable element inside the modal
@@ -29,13 +37,18 @@
focusable?.focus();
});
});
} else {
} else if (mounted) {
visible = false;
// Restore focus to the previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
mounted = false;
closeTimer = null;
}, TRANSITION_MS);
}
});
@@ -73,7 +86,7 @@
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
{#if mounted}
<div use:portal class="modal-portal-root">
<div
class="modal-backdrop"
+79 -2
View File
@@ -124,6 +124,15 @@
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"loadingEvents": "Loading events...",
"heldUntil": "held until",
"deferredTitle": "Quiet hours suppressed this notification; it will dispatch when the window ends.",
"deliveredLate": "delivered late",
"deliveredLateTitle": "This notification fired after the quiet-hours window ended.",
"deferredThenDropped": "dropped after defer",
"deferredThenDroppedTitle": "Held by quiet hours, then dropped — the target or link was removed before the window ended.",
"deferredThenFailed": "failed after defer",
"suppressedQuietHours": "suppressed (quiet hours)",
"suppressedNondeferrableTitle": "Wall-clock event suppressed by quiet hours. Scheduled/periodic/memory dispatches drop rather than defer.",
"asset": "asset",
"assets": "assets",
"eventActivity": "Event Activity",
@@ -179,7 +188,21 @@
"openCommandTracker": "Open command tracker",
"openAction": "Open action",
"openTracker": "Open tracker",
"rawDetails": "Raw details"
"rawDetails": "Raw details",
"lifecycle": {
"heldTitle": "Held by quiet hours",
"heldUntil": "Will dispatch at",
"heldFor": "Held for",
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
"inPrefix": "in",
"deliveredLateTitle": "Delivered after quiet hours",
"originalEvent": "Original event",
"droppedTitle": "Dropped after defer",
"failedTitle": "Failed after defer",
"reason": "Reason",
"suppressedTitle": "Suppressed by quiet hours",
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
}
},
"providers": {
"title": "Service",
@@ -474,6 +497,7 @@
"countLabel": "users",
"title": "Users",
"description": "Manage user accounts (admin only)",
"you": "you",
"addUser": "Add User",
"cancel": "Cancel",
"username": "Username",
@@ -870,7 +894,58 @@
"changedOne": "1 setting changed",
"changedMany": "{n} settings changed",
"discard": "Discard",
"saveChanges": "Save changes"
"saveChanges": "Save changes",
"release": {
"eyebrow": "Releases",
"headline": "Stay current with upstream",
"provider": "Provider",
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
"comingSoon": "Coming soon",
"disabled": "Disabled",
"repository": "Repository",
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
"options": "Options",
"includePrereleases": "Include pre-releases",
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
"interval": "Check interval",
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
"intervalRange": "1168 hrs",
"hoursUnit": "hrs",
"testConnection": "Test connection",
"checkNow": "Check now",
"checkDone": "Release check complete",
"checkFailed": "Release check failed",
"testOk": "Provider reachable",
"testFailed": "Provider unreachable",
"testFound": "Provider returned",
"viewRelease": "View v{v} release",
"statusUpToDate": "You're up to date",
"statusUpdate": "Update available",
"statusDisabled": "Release checks disabled",
"statusError": "Last check failed",
"statusUnknown": "Not checked yet",
"heroAvailable": "available",
"updateAvailableTooltip": "v{v} available — open Settings",
"lastChecked": "Last checked",
"never": "never",
"justNow": "just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{n} d ago",
"error": {
"disabled": "Release checks are disabled",
"misconfigured": "Provider not fully configured",
"provider_changed": "Provider changed — awaiting next check",
"no_release_found": "No matching release found upstream",
"network_error": "Upstream unreachable",
"http_error": "Upstream returned an error",
"parse_error": "Upstream response could not be parsed",
"unsafe_url": "URL rejected by safety check",
"not_implemented": "Provider not implemented yet",
"unknown_error": "Unknown error",
"error": "Last check failed"
}
}
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
@@ -1031,6 +1106,8 @@
"noMatches": "No timezones match"
},
"locales": {
"label": "language",
"labelPlural": "languages",
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
+79 -2
View File
@@ -124,6 +124,15 @@
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"heldUntil": "ожидает до",
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
"deliveredLate": "доставлено позже",
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
"deferredThenDropped": "отброшено после задержки",
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
"deferredThenFailed": "ошибка после задержки",
"suppressedQuietHours": "подавлено (тихие часы)",
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
"asset": "файл",
"assets": "файлов",
"eventActivity": "Активность событий",
@@ -179,7 +188,21 @@
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные"
"rawDetails": "Сырые данные",
"lifecycle": {
"heldTitle": "Задержано тихими часами",
"heldUntil": "Будет отправлено в",
"heldFor": "Задержано на",
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
"inPrefix": "через",
"deliveredLateTitle": "Доставлено после тихих часов",
"originalEvent": "Исходное событие",
"droppedTitle": "Отброшено после задержки",
"failedTitle": "Ошибка после задержки",
"reason": "Причина",
"suppressedTitle": "Подавлено тихими часами",
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
}
},
"providers": {
"title": "Сервисные",
@@ -474,6 +497,7 @@
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"you": "вы",
"addUser": "Добавить пользователя",
"cancel": "Отмена",
"username": "Имя пользователя",
@@ -870,7 +894,58 @@
"changedOne": "Изменена 1 настройка",
"changedMany": "Изменено настроек: {n}",
"discard": "Отменить",
"saveChanges": "Сохранить"
"saveChanges": "Сохранить",
"release": {
"eyebrow": "Релизы",
"headline": "Следите за обновлениями",
"provider": "Источник",
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
"comingSoon": "Скоро",
"disabled": "Отключено",
"repository": "Репозиторий",
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
"options": "Опции",
"includePrereleases": "Учитывать пре-релизы",
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
"interval": "Интервал проверки",
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
"intervalRange": "1168 ч",
"hoursUnit": "ч",
"testConnection": "Проверить связь",
"checkNow": "Проверить сейчас",
"checkDone": "Проверка релизов завершена",
"checkFailed": "Не удалось проверить релизы",
"testOk": "Источник доступен",
"testFailed": "Источник недоступен",
"testFound": "Найдена версия",
"viewRelease": "Открыть релиз v{v}",
"statusUpToDate": "Актуальная версия",
"statusUpdate": "Доступно обновление",
"statusDisabled": "Проверка релизов отключена",
"statusError": "Ошибка последней проверки",
"statusUnknown": "Ещё не проверялось",
"heroAvailable": "доступна",
"updateAvailableTooltip": "Доступна версия v{v} — открыть Настройки",
"lastChecked": "Последняя проверка",
"never": "никогда",
"justNow": "только что",
"minutesAgo": "{n} мин назад",
"hoursAgo": "{n} ч назад",
"daysAgo": "{n} д назад",
"error": {
"disabled": "Проверка релизов отключена",
"misconfigured": "Источник настроен не полностью",
"provider_changed": "Источник изменён — ожидание следующей проверки",
"no_release_found": "Подходящий релиз на источнике не найден",
"network_error": "Источник недоступен",
"http_error": "Источник вернул ошибку",
"parse_error": "Не удалось разобрать ответ источника",
"unsafe_url": "URL отклонён проверкой безопасности",
"not_implemented": "Источник пока не реализован",
"unknown_error": "Неизвестная ошибка",
"error": "Ошибка последней проверки"
}
}
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -1031,6 +1106,8 @@
"noMatches": "Нет совпадений"
},
"locales": {
"label": "язык",
"labelPlural": "языков",
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
+47
View File
@@ -20,6 +20,7 @@ import type {
CommandTemplateConfig,
CommandTracker,
Action,
ReleaseStatus,
} from '$lib/types';
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
@@ -140,6 +141,46 @@ export const externalUrlCache = (() => {
};
})();
/** Upstream release status — drives the sidebar badge and Settings cassette. */
export const releaseStatusCache = (() => {
let data = $state<ReleaseStatus | null>(null);
let fetchedAt = $state(0);
let inflight: Promise<ReleaseStatus | null> | null = null;
// 5 min TTL — fresh enough that "Check now" feels instant on revisit,
// long enough that route changes don't hammer the endpoint.
const TTL = 300_000;
return {
get value() { return data; },
invalidate() { fetchedAt = 0; },
clear() {
data = null;
fetchedAt = 0;
inflight = null;
},
set(next: ReleaseStatus | null) {
data = next;
fetchedAt = Date.now();
},
async fetch(force = false): Promise<ReleaseStatus | null> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
if (inflight) return inflight;
inflight = (async () => {
try {
data = await api<ReleaseStatus>('/settings/release');
fetchedAt = Date.now();
return data;
} catch {
// Swallow — the badge falls back to its default "no status" state.
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
/** Supported template locales — fetched from app settings. */
export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']);
@@ -192,7 +233,13 @@ export async function fetchAllCaches(): Promise<void> {
/**
* Invalidate all entity caches. Useful on logout.
*
* Singleton state caches (release status, external URL, supported locales)
* live outside `allCaches` because their shape differs from entity caches —
* we clear them explicitly so a returning user as a different role can't
* briefly see the previous user's cached payload.
*/
export function clearAllCaches(): void {
Object.values(allCaches).forEach(c => c.clear());
releaseStatusCache.clear();
}
+59 -1
View File
@@ -212,6 +212,29 @@ export interface TemplateConfig {
created_at: string;
}
/**
* Lifecycle marker the backend stores in ``EventLog.details.dispatch_status``
* when a notification doesn't take the immediate-deliver happy path.
*
* * ``deferred`` — held back by quiet hours; ``deferred_until`` carries the
* UTC ISO datetime at which a drain job will fire.
* * ``delivered_after_quiet_hours`` — a drain successfully dispatched the
* originally-deferred event. ``original_event_log_id`` points back at the
* row from when the event was first detected.
* * ``deferred_then_dropped`` — drain time arrived but the link/target was
* removed, disabled, or otherwise unsendable. See ``reason`` for detail.
* * ``deferred_then_failed`` — drain dispatched but the target returned an
* error; ``reason`` carries the truncated provider error.
* * ``suppressed_quiet_hours_nondeferrable`` — wall-clock event type (e.g.
* ``scheduled_message``) caught by quiet hours, dropped on principle.
*/
export type DispatchStatus =
| 'deferred'
| 'delivered_after_quiet_hours'
| 'deferred_then_dropped'
| 'deferred_then_failed'
| 'suppressed_quiet_hours_nondeferrable';
export interface EventLog {
id: number;
event_type: string;
@@ -228,7 +251,12 @@ export interface EventLog {
telegram_bot_id?: number | null;
bot_name?: string;
assets_count: number;
details: Record<string, any>;
details: Record<string, any> & {
dispatch_status?: DispatchStatus;
deferred_until?: string;
original_event_log_id?: number | null;
deferred_for_seconds?: number;
};
created_at: string;
}
@@ -345,3 +373,33 @@ export interface DashboardStatus {
recent_events: EventLog[];
command_trackers?: number;
}
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
export interface ReleaseStatus {
provider: ReleaseProviderKind;
current: string;
latest: string | null;
latest_tag: string | null;
latest_url: string | null;
latest_name: string | null;
latest_body: string | null;
latest_published_at: string | null;
latest_prerelease: boolean;
checked_at: string | null;
update_available: boolean;
error: string | null;
}
export interface ReleaseTestResult {
ok: boolean;
info: {
tag: string;
version: string;
name: string | null;
url: string | null;
published_at: string | null;
prerelease: boolean;
} | null;
error: string | null;
}
+61 -2
View File
@@ -18,7 +18,7 @@
providersCache, notificationTrackersCache, trackingConfigsCache,
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
matrixBotsCache, targetsCache,
matrixBotsCache, targetsCache, releaseStatusCache,
} from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
@@ -31,6 +31,17 @@
let allProviders = $derived(providersCache.items);
// Sidebar release indicator — reads from the cache populated in onMount.
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
// A screen reader hits the brand-version link on every page — keep the
// label informative only when an update is available, otherwise announce
// the version + product so we don't repeat "Up to date" everywhere.
const releaseTooltip = $derived(
releaseUpdateAvailable
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
: `Notify Bridge v${__APP_VERSION__}`
);
let providerFilterItems = $derived([
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
@@ -306,6 +317,7 @@
emailBotsCache.fetch(),
matrixBotsCache.fetch(),
targetsCache.fetch(),
releaseStatusCache.fetch(),
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
}
});
@@ -401,7 +413,20 @@
{/if}
Notify Bridge
</h1>
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
<p class="brand-version font-mono">
<a
class="brand-version-link"
class:has-update={releaseUpdateAvailable}
href="/settings#release"
aria-label={releaseTooltip}
title={releaseUpdateAvailable ? releaseTooltip : undefined}
>
<span>v{__APP_VERSION__}</span>
{#if releaseUpdateAvailable}
<span class="brand-version-dot" aria-hidden="true"></span>
{/if}
</a>
</p>
</div>
</div>
{:else}
@@ -772,6 +797,40 @@
letter-spacing: 0.02em;
font-weight: 500;
}
.brand-version-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: inherit;
text-decoration: none;
border-radius: 0.3rem;
padding: 1px 4px;
margin: -1px -4px;
transition: color 0.15s, background 0.15s;
}
.brand-version-link:hover {
color: var(--color-foreground);
background: var(--color-glass-strong);
}
.brand-version-link.has-update {
color: var(--color-citrus, #d4a73a);
}
.brand-version-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-citrus, #d4a73a);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
animation: brand-version-pulse 2.4s ease-in-out infinite;
}
@keyframes brand-version-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.brand-version-dot { animation: none; }
.brand-version-link { transition: none; }
}
.brand-orb {
width: 32px; height: 32px;
border-radius: 11px;
+61
View File
@@ -724,6 +724,37 @@
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if}
</div>
{#if event.details?.dispatch_status === 'deferred' && event.details?.deferred_until}
<span class="dispatch-badge dispatch-badge--deferred"
title={t('dashboard.deferredTitle')}>
<MdiIcon name="mdiPauseCircleOutline" size={12} />
{t('dashboard.heldUntil')} {timeShort(event.details.deferred_until)}
</span>
{:else if event.details?.dispatch_status === 'delivered_after_quiet_hours'}
<span class="dispatch-badge dispatch-badge--late"
title={t('dashboard.deliveredLateTitle')}>
<MdiIcon name="mdiClockCheckOutline" size={12} />
{t('dashboard.deliveredLate')}
</span>
{:else if event.details?.dispatch_status === 'deferred_then_dropped'}
<span class="dispatch-badge dispatch-badge--dropped"
title={t('dashboard.deferredThenDroppedTitle')}>
<MdiIcon name="mdiCloseCircleOutline" size={12} />
{t('dashboard.deferredThenDropped')}
</span>
{:else if event.details?.dispatch_status === 'deferred_then_failed'}
<span class="dispatch-badge dispatch-badge--dropped"
title={event.details?.reason ?? ''}>
<MdiIcon name="mdiAlertCircleOutline" size={12} />
{t('dashboard.deferredThenFailed')}
</span>
{:else if event.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<span class="dispatch-badge dispatch-badge--dropped"
title={t('dashboard.suppressedNondeferrableTitle')}>
<MdiIcon name="mdiVolumeOff" size={12} />
{t('dashboard.suppressedQuietHours')}
</span>
{/if}
{#if event.event_type?.startsWith('command_')}
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
{@const issuerLabel = issuer
@@ -1334,6 +1365,36 @@
border-radius: 6px;
}
.signal-trail .arrow { color: var(--color-muted-foreground); }
/* Dispatch lifecycle badges (quiet-hours deferral, late delivery, drops).
* Coloured to match the verb (held = primary glow, late = success, drop
* = muted). The icon is intentionally small so the badge doesn't pull
* focus from the event verb itself. */
.dispatch-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.68rem;
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
margin-left: 0.4rem;
white-space: nowrap;
}
.dispatch-badge--deferred {
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-glass-strong));
}
.dispatch-badge--late {
color: var(--color-success, #16a34a);
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 10%, var(--color-glass-strong));
}
.dispatch-badge--dropped {
color: var(--color-muted-foreground);
opacity: 0.85;
}
.signal-when {
text-align: right;
font-size: 0.7rem;
+70 -21
View File
@@ -22,6 +22,7 @@
import ExecutionHistory from './ExecutionHistory.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { Action, ActionRule } from '$lib/types';
let allActions = $derived(actionsCache.items);
@@ -193,6 +194,51 @@
if (status === 'failed') return 'var(--color-error-fg)';
return 'var(--color-muted-foreground)';
}
function statusTone(status: string | undefined): MetaTile['tone'] {
if (status === 'success') return 'mint';
if (status === 'partial') return 'citrus';
if (status === 'failed') return 'coral';
return 'default';
}
function actionTiles(action: Action): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push(action.enabled
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('commandTracker.disabled'), tone: 'default' });
tiles.push({
icon: 'mdiServer',
label: getProviderName(action.provider_id),
tone: 'lavender',
});
tiles.push({
icon: 'mdiTagOutline',
label: action.action_type,
tone: 'sky',
mono: true,
});
tiles.push({
icon: action.schedule_type === 'cron' ? 'mdiClockOutline' : 'mdiTimerOutline',
label: formatSchedule(action),
tone: 'orchid',
mono: true,
});
tiles.push({
icon: 'mdiFormatListBulleted',
value: String(action.rules?.length || 0),
label: t('actions.rules'),
tone: (action.rules?.length || 0) > 0 ? 'sky' : 'default',
});
if (action.last_run_status) {
tiles.push({
icon: 'mdiHistory',
label: action.last_run_status,
tone: statusTone(action.last_run_status),
});
}
return tiles;
}
</script>
<PageHeader
@@ -323,32 +369,35 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each actions as action}
<Card hover entityId={action.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{action.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
<span style="color: {statusColor(action.last_run_status)}">
{action.last_run_status}
</span>
{/if}
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3 min-w-0">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{action.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)] list-row__secondary">
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
<span style="color: {statusColor(action.last_run_status)}">
{action.last_run_status}
</span>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={actionTiles(action)} />
<div class="list-row__actions">
<IconButton icon="mdiPlay" title={t('actions.execute')}
onclick={() => executeAction(action.id)}
disabled={executing[action.id]} />
+34 -8
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -39,6 +40,30 @@
}
});
function emailBotTiles(bot: EmailBot): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiEmailOutline',
label: bot.email,
tone: 'lavender',
mono: true,
});
tiles.push({
icon: 'mdiServerNetwork',
label: `${bot.smtp_host}:${bot.smtp_port}`,
tone: 'sky',
mono: true,
});
if (bot.smtp_use_tls) {
tiles.push({
icon: 'mdiLockOutline',
label: 'TLS',
tone: 'mint',
});
}
return tiles;
}
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
@@ -165,16 +190,16 @@
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each emailBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
@@ -182,7 +207,8 @@
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={emailBotTiles(bot)} />
<div class="list-row__actions">
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
+32 -8
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -38,6 +39,28 @@
}
});
function matrixBotTiles(bot: MatrixBot): MetaTile[] {
const tiles: MetaTile[] = [];
let host = bot.homeserver_url;
try { host = new URL(bot.homeserver_url).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiServerNetwork',
label: host,
hint: bot.homeserver_url,
href: bot.homeserver_url,
tone: 'lavender',
mono: true,
});
if (bot.display_name) {
tiles.push({
icon: 'mdiAccountCircleOutline',
label: bot.display_name,
tone: 'sky',
});
}
return tiles;
}
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
@@ -148,23 +171,24 @@
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each matrixBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
{#if bot.display_name}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={matrixBotTiles(bot)} />
<div class="list-row__actions">
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
+43 -10
View File
@@ -16,6 +16,7 @@
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { TelegramBot, TelegramChat } from '$lib/types';
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
@@ -60,6 +61,36 @@
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function telegramBotTiles(bot: TelegramBot): MetaTile[] {
const tiles: MetaTile[] = [];
const mode = bot.update_mode || 'none';
const modeTone: MetaTile['tone'] = mode === 'webhook' ? 'lavender' : mode === 'polling' ? 'mint' : 'default';
const modeLabel = mode === 'webhook' ? t('telegramBot.webhook') : mode === 'polling' ? t('telegramBot.polling') : t('telegramBot.none');
tiles.push({
icon: mode === 'webhook' ? 'mdiWebhook' : mode === 'polling' ? 'mdiSync' : 'mdiPowerOff',
label: modeLabel,
tone: modeTone,
});
if (bot.bot_username) {
tiles.push({
icon: 'mdiAt',
label: bot.bot_username,
tone: 'sky',
mono: true,
});
}
const chatCount = chats[bot.id]?.length;
if (chatCount !== undefined) {
tiles.push({
icon: 'mdiChat',
value: String(chatCount),
label: t('telegramBot.chats'),
tone: chatCount > 0 ? 'orchid' : 'default',
});
}
return tiles;
}
function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; nameManuallyEdited = true; editing = bot.id; showForm = true; }
@@ -343,18 +374,19 @@
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
</div>
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: (bot.update_mode || 'none') === 'polling'
@@ -362,10 +394,11 @@
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<MetaStrip tiles={telegramBotTiles(bot)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
disabled={chatsLoading[bot.id]}
@@ -22,6 +22,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { getDescriptor } from '$lib/providers';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
@@ -108,6 +109,42 @@
finally { loaded = true; highlightFromUrl(); }
}
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: cfg.provider_type,
tone: 'lavender',
mono: true,
});
const cmdCount = (cfg.enabled_commands || []).length;
tiles.push({
icon: 'mdiSlashForward',
value: String(cmdCount),
label: t('commandConfig.commands'),
tone: cmdCount > 0 ? 'mint' : 'coral',
});
tiles.push({
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
tone: 'sky',
});
tiles.push({
icon: 'mdiNumeric',
value: String(cfg.default_count),
label: t('commandConfig.defaultCount'),
tone: 'citrus',
});
if (cfg.command_template_config_id) {
tiles.push({
icon: 'mdiCodeBracesBox',
label: templateName(cfg.command_template_config_id),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
// Auto-select first provider type with commands
@@ -316,22 +353,20 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as cfg}
<Card hover entityId={cfg.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)] font-mono">
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{cfg.provider_type}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<div class="flex items-center gap-2 mt-0.5 list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)]">
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
&middot; {t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
&middot; {t('commandConfig.defaultCount')}: {cfg.default_count}
</span>
{#if cfg.command_template_config_id}
@@ -339,7 +374,8 @@
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandConfigTiles(cfg)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
</div>
@@ -27,6 +27,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
interface CmdTemplateConfig {
id: number;
@@ -262,6 +263,44 @@
}
}
function cmdTemplateConfigTiles(config: CmdTemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
@@ -587,25 +626,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -21,6 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
@@ -272,6 +273,32 @@
function configName(id: number): string {
return commandConfigs.find(c => c.id === id)?.name || '?';
}
function commandTrackerTiles(trk: any): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push(trk.enabled
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
: { icon: 'mdiCloseCircle', label: t('commandTracker.disabled'), tone: 'coral' });
tiles.push({
icon: 'mdiServer',
label: providerName(trk.provider_id),
tone: 'lavender',
});
tiles.push({
icon: 'mdiCog',
label: configName(trk.command_config_id),
tone: 'sky',
});
if (trk.listener_count !== undefined) {
tiles.push({
icon: 'mdiAccountMultipleOutline',
value: String(trk.listener_count),
label: t('commandTracker.listeners').toLowerCase(),
tone: trk.listener_count > 0 ? 'orchid' : 'default',
});
}
return tiles;
}
</script>
<PageHeader
@@ -341,29 +368,32 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each trackers as trk}
<Card hover entityId={trk.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
{#if trk.listener_count !== undefined}
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</p>
{/if}
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
{#if trk.listener_count !== undefined}
<span class="text-xs text-[var(--color-muted-foreground)]">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandTrackerTiles(trk)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
@@ -22,6 +22,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
import SharedLinkModal from './SharedLinkModal.svelte';
@@ -374,6 +375,54 @@
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
}
/**
* Meta tiles for a tracker row. Visible on lg+ in the dead middle space
* between identity and actions. Mirrors the secondary text shown on narrow
* screens, but as live tiles users can scan at a glance.
*/
function trackerTiles(tracker: Tracker): MetaTile[] {
const tiles: MetaTile[] = [];
const trkDesc = getDescriptor(getProviderType(tracker));
// Status — armed/paused with color tone
tiles.push(tracker.enabled
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
// Provider
tiles.push({
icon: 'mdiServer',
label: getProviderName(tracker.provider_id),
tone: 'lavender',
});
// Collections — count + label (varies per provider descriptor)
const collCount = (tracker.collection_ids || []).length;
if (collCount > 0 || !trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiFolderMultipleOutline',
value: String(collCount),
label: getCollectionLabel(tracker),
tone: 'sky',
});
}
// Scan interval — only meaningful for polling trackers
if (!trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiTimerOutline',
value: `${tracker.scan_interval}s`,
label: t('notificationTracker.every').trim(),
tone: 'orchid',
});
}
// Linked targets
const tgtCount = (tracker.tracker_targets || []).length;
tiles.push({
icon: 'mdiTarget',
value: String(tgtCount),
label: t('notificationTracker.linkedTargets'),
tone: tgtCount > 0 ? 'mint' : 'coral',
});
return tiles;
}
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
@@ -528,27 +577,30 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each notificationTrackers as tracker (tracker.id)}
{@const trkDesc = getDescriptor(getProviderType(tracker))}
<Card hover entityId={tracker.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium truncate">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
</span>
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
<div class="list-row__secondary mt-0.5">
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
</div>
</div>
<div class="flex items-center gap-1 flex-wrap justify-end">
<MetaStrip tiles={trackerTiles(tracker)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
+99 -29
View File
@@ -25,6 +25,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
import type { ServiceProvider } from '$lib/types';
@@ -52,6 +53,67 @@
return externalUrl ? `${externalUrl}${path}` : path;
}
/**
* Build meta tiles for a provider row. Filled into the dead middle space
* on wide displays; on narrow screens the secondary text line takes over.
*/
function providerTiles(provider: ServiceProvider): MetaTile[] {
const tiles: MetaTile[] = [];
const h = health[provider.id];
const provDesc = getDescriptor(provider.type);
// Status — first tile, color-coded
if (h === true) {
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
} else if (h === false) {
tiles.push({ icon: 'mdiCloseCircle', label: t('providers.offline'), tone: 'coral' });
} else {
tiles.push({ icon: 'mdiTimerSand', label: t('providers.checking'), tone: 'citrus' });
}
// Type / connection address
const cfg = provider.config as Record<string, any> | undefined;
if (cfg?.url) {
tiles.push({
icon: 'mdiLinkVariant',
label: shortenUrl(cfg.url),
hint: cfg.url,
href: cfg.url,
tone: 'sky',
mono: true,
});
} else if (cfg?.host) {
tiles.push({
icon: 'mdiServer',
label: `${cfg.host}:${cfg.port || 3493}`,
tone: 'sky',
mono: true,
});
}
// Webhook URL (copy to clipboard)
if (provDesc?.webhookUrlPattern) {
const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token);
tiles.push({
icon: 'mdiContentCopy',
label: t('providers.webhookUrl'),
hint: webhookUrl,
tone: 'orchid',
onclick: (e) => copyWebhookUrl(e, webhookUrl),
});
}
return tiles;
}
/** Trim the visible URL so it fits a meta tile; keep host + first path segment. */
function shortenUrl(url: string): string {
try {
const u = new URL(url);
const segments = u.pathname.split('/').filter(Boolean);
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
return `${u.host}${tail}`;
} catch {
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
}
}
function copyWebhookUrl(e: Event, url: string) {
e.preventDefault();
e.stopPropagation();
@@ -222,7 +284,7 @@
{/if}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<div in:slide={{ duration: 200 }} class="list-stack">
<Card class="mb-6">
<ErrorBanner message={error} />
<form onsubmit={save} class="space-y-3">
@@ -292,9 +354,11 @@
{/if}
{#if !showForm && allProviders.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="list-stack mb-3">
<div class="flex items-center gap-2">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{/if}
@@ -307,37 +371,43 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each providers as provider}
{@const provDesc = getDescriptor(provider.type)}
<Card hover entityId={provider.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
</div>
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
<div class="list-row__secondary">
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={providerTiles(provider)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
</div>
+28 -1
View File
@@ -6,11 +6,12 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache } from '$lib/stores/caches.svelte';
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
import SettingsHero from './SettingsHero.svelte';
import IdentityCassette from './IdentityCassette.svelte';
import TelegramCassette from './TelegramCassette.svelte';
import ReleaseCassette from './ReleaseCassette.svelte';
import CacheLedger from './CacheLedger.svelte';
import LoggingCassette from './LoggingCassette.svelte';
import SaveBar from './SaveBar.svelte';
@@ -36,6 +37,11 @@
log_level: string;
log_format: string;
log_levels: string;
release_provider_kind: string;
release_provider_url: string;
release_provider_repo: string;
release_include_prereleases: string;
release_check_interval_hours: string;
}
const EMPTY: Settings = {
@@ -48,6 +54,11 @@
log_level: 'INFO',
log_format: 'text',
log_levels: '',
release_provider_kind: 'gitea',
release_provider_url: 'https://git.dolgolyov-family.by',
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
release_include_prereleases: '0',
release_check_interval_hours: '12',
};
let loaded = $state(false);
@@ -86,6 +97,8 @@
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats();
// Warm the release status so the cassette renders the strip on first paint.
await releaseStatusCache.fetch();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load settings';
error = msg;
@@ -108,6 +121,12 @@
settings = { ...EMPTY, ...next };
baseline = { ...settings };
externalUrlCache.invalidate();
// Release config may have changed → drop the cached status and
// refetch so the sidebar badge + cassette strip reflect the
// freshly-rescheduled probe without waiting for the next route
// change to trigger another read.
releaseStatusCache.invalidate();
void releaseStatusCache.fetch(true);
snackSuccess(t('settings.saved'));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
@@ -171,6 +190,14 @@
/>
</div>
<ReleaseCassette
bind:providerKind={settings.release_provider_kind}
bind:providerUrl={settings.release_provider_url}
bind:providerRepo={settings.release_provider_repo}
bind:includePrereleases={settings.release_include_prereleases}
bind:checkIntervalHours={settings.release_check_interval_hours}
/>
<LoggingCassette
bind:logLevel={settings.log_level}
bind:logFormat={settings.log_format}
@@ -2,6 +2,7 @@
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import Hint from '$lib/components/Hint.svelte';
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
@@ -104,6 +105,7 @@
{#if totalBytes > 0}
<span class="ledger-sep">·</span>
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
<Hint text={t('settings.cacheStatsHint')} />
{/if}
</div>
</div>
@@ -0,0 +1,698 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import { api } from '$lib/api';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
interface Props {
// All five fields are persisted as strings via the /settings PUT —
// the parent owns the boundary type. Bool flags use "0" / "1".
providerKind: string;
providerUrl: string;
providerRepo: string;
includePrereleases: string;
checkIntervalHours: string;
}
let {
providerKind = $bindable(),
providerUrl = $bindable(),
providerRepo = $bindable(),
includePrereleases = $bindable(),
checkIntervalHours = $bindable(),
}: Props = $props();
let checking = $state(false);
let testing = $state(false);
let testResult = $state<ReleaseTestResult | null>(null);
const status = $derived(releaseStatusCache.value);
const prereleaseChecked = $derived(includePrereleases === '1');
const isDisabled = $derived(providerKind === 'disabled');
// Stale Test-result on input change is misleading — wipe whenever any of
// the probed parameters change so the strip reflects "current" state.
$effect(() => {
// Touch each parameter to register dependency.
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
testResult = null;
});
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
const stateTone: Tone = $derived.by(() => {
if (!status) return 'sky';
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
if (status.update_available) return 'citrus';
if (status.provider === 'disabled') return 'sky';
return 'mint';
});
const stateLabel = $derived.by(() => {
if (!status) return t('settings.release.statusUnknown');
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
if (status.update_available) return t('settings.release.statusUpdate');
if (status.latest) return t('settings.release.statusUpToDate');
return t('settings.release.statusUnknown');
});
// Map backend error taxonomy → localized text. Falls back to the raw code
// only when the key is missing (so a new server code surfaces something).
function localizedError(code: string | null): string {
if (!code) return '';
const key = `settings.release.error.${code}`;
const localized = t(key);
// `t` falls back to the key itself when missing — detect by exact match.
return localized === key ? code : localized;
}
function relTime(iso: string | null): string {
if (!iso) return t('settings.release.never');
const then = Date.parse(iso);
if (!Number.isFinite(then)) return t('settings.release.never');
const diff = Date.now() - then;
const min = Math.round(diff / 60_000);
if (min < 1) return t('settings.release.justNow');
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
const h = Math.round(min / 60);
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
const d = Math.round(h / 24);
return t('settings.release.daysAgo').replace('{n}', String(d));
}
function setProvider(kind: ReleaseProviderKind): void {
providerKind = kind;
}
function onIntervalInput(e: Event): void {
// The native input emits string values; we keep the contract by
// re-coercing to string before assigning to the bindable prop.
const raw = (e.currentTarget as HTMLInputElement).value;
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
}
async function checkNow(): Promise<void> {
checking = true;
try {
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
releaseStatusCache.set(next);
snackSuccess(t('settings.release.checkDone'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
} finally {
checking = false;
}
}
async function testProvider(): Promise<void> {
testing = true;
testResult = null;
try {
testResult = await api<ReleaseTestResult>('/settings/release/test', {
method: 'POST',
body: JSON.stringify({
provider_kind: providerKind,
provider_url: providerUrl,
provider_repo: providerRepo,
include_prereleases: prereleaseChecked,
}),
});
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
else snackError(t('settings.release.testFailed'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
} finally {
testing = false;
}
}
</script>
<section class="rel glass" id="release">
<header class="rel-head">
<div class="rel-eyebrow">
<MdiIcon name="mdiUpdate" size={12} />
<span>{t('settings.release.eyebrow')}</span>
</div>
<h3 class="rel-title">{t('settings.release.headline')}</h3>
</header>
<div class="rel-body">
<!-- 01 Provider — native radios for free keyboard a11y. -->
<div class="row">
<div class="row-label">
<span class="row-num">01</span>
<span class="row-name">
{t('settings.release.provider')}
<Hint text={t('settings.release.providerHint')} />
</span>
</div>
<div class="row-control">
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
<input
type="radio"
name="release-provider"
value="gitea"
checked={providerKind === 'gitea'}
onchange={() => setProvider('gitea')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
</label>
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
<input
type="radio"
name="release-provider"
value="github"
disabled
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
</label>
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
<input
type="radio"
name="release-provider"
value="disabled"
checked={providerKind === 'disabled'}
onchange={() => setProvider('disabled')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
</label>
</div>
</div>
</div>
<!-- 02 Repository -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">02</span>
<span class="row-name">
{t('settings.release.repository')}
<Hint text={t('settings.release.repositoryHint')} />
</span>
</div>
<div class="row-control repo-grid">
<input
bind:value={providerUrl}
placeholder="https://git.example.com"
class="text-input"
type="url"
spellcheck="false"
disabled={isDisabled}
/>
<input
bind:value={providerRepo}
placeholder="owner/repo"
class="text-input mono"
spellcheck="false"
disabled={isDisabled}
/>
</div>
</div>
<!-- 03 Options — slider toggle for include-prereleases. -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">03</span>
<span class="row-name">
{t('settings.release.options')}
<Hint text={t('settings.release.prereleasesHint')} />
</span>
</div>
<div class="row-control">
<button
type="button"
class="toggle"
class:toggle-disabled={isDisabled}
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
aria-pressed={prereleaseChecked}
disabled={isDisabled}
>
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
</button>
</div>
</div>
<!-- 04 Check interval -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">04</span>
<span class="row-name">
{t('settings.release.interval')}
<Hint text={t('settings.release.intervalHint')} />
</span>
</div>
<div class="row-control interval">
<input
type="number"
min={1}
max={168}
value={checkIntervalHours}
oninput={onIntervalInput}
class="text-input num"
disabled={isDisabled}
/>
<span class="unit">{t('settings.release.hoursUnit')}</span>
<span class="footnote">{t('settings.release.intervalRange')}</span>
</div>
</div>
</div>
<!-- State strip -->
<footer class="strip" data-tone={stateTone}>
<div class="strip-left">
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
<div class="strip-text">
<div class="strip-state">{stateLabel}</div>
<div class="strip-meta">
<span class="versions">
<span class="v-current">v{status?.current ?? '—'}</span>
{#if status?.latest && status.latest !== status.current}
<span class="arrow" aria-hidden="true"></span>
<span
class="v-latest"
class:v-latest-update={status.update_available}
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
{/if}
</span>
<span class="sep" aria-hidden="true">·</span>
<span class="checked">
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
</span>
</div>
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
</div>
{/if}
{#if testResult && !testResult.ok}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
{localizedError(testResult.error)}
</div>
{/if}
{#if testResult && testResult.ok && testResult.info}
<div class="strip-test-ok">
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
<span class="mono">v{testResult.info.version}</span>
</div>
{/if}
</div>
</div>
<div class="strip-actions">
{#if status?.update_available && status.latest_url}
<a
class="strip-btn strip-btn-cta"
href={status.latest_url}
target="_blank"
rel="noopener noreferrer"
>
<MdiIcon name="mdiOpenInNew" size={13} />
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
</a>
{/if}
<button
type="button"
class="strip-btn"
onclick={testProvider}
disabled={testing || isDisabled || !providerRepo}
>
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
<span>{t('settings.release.testConnection')}</span>
</button>
<button
type="button"
class="strip-btn strip-btn-primary"
onclick={checkNow}
disabled={checking || isDisabled}
>
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
<span>{t('settings.release.checkNow')}</span>
</button>
</div>
</footer>
</section>
<style>
.rel {
padding: 1.5rem 1.6rem 0;
display: flex;
flex-direction: column;
gap: 1.2rem;
overflow: hidden;
}
.rel-head { position: relative; z-index: 1; }
.rel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.rel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: 1.25rem;
line-height: 1.3;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 42ch;
}
.rel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 11rem 1fr;
gap: 1.4rem;
padding: 1rem 0;
border-top: 1px solid var(--color-border);
}
.row:first-child { border-top: 0; padding-top: 0.4rem; }
.row-dim { opacity: 0.55; }
.row-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.15rem;
}
.row-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.row-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
display: inline-flex;
align-items: center;
}
.row-control { min-width: 0; }
/* Segmented provider control — uses real radios so arrow-key + tab
navigation just work via the browser. */
.seg {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
}
.seg-item {
display: inline-flex;
align-items: center;
border-radius: 0.45rem;
cursor: pointer;
position: relative;
}
.seg-radio {
position: absolute;
opacity: 0;
pointer-events: none;
inset: 0;
}
.seg-radio:focus-visible + .seg-content {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.seg-content {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: 0.45rem;
font-size: 0.78rem;
color: var(--color-muted-foreground);
transition: background 0.18s, color 0.18s;
}
.seg-item:hover:not(.seg-soon) .seg-content {
color: var(--color-foreground);
background: var(--color-glass);
}
.seg-active .seg-content {
color: var(--color-foreground);
background: var(--color-input-bg);
box-shadow: 0 0 0 1px var(--color-primary);
}
.seg-soon { opacity: 0.45; cursor: not-allowed; }
/* Text fields */
.repo-grid {
display: grid;
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
gap: 0.6rem;
max-width: 100%;
}
.text-input {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
background: var(--color-input-bg);
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--color-foreground);
transition: border-color 0.18s, box-shadow 0.18s;
}
.text-input.mono { font-family: var(--font-mono); }
.text-input.num { max-width: 6rem; text-align: right; }
.text-input:focus {
outline: 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
/* Interval */
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.unit {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.footnote {
font-size: 0.68rem;
color: var(--color-muted-foreground);
font-style: italic;
}
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
.toggle {
display: inline-flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: var(--color-foreground);
cursor: pointer;
}
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
.toggle-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--color-muted-foreground);
transition: transform 0.2s, background 0.2s;
}
.toggle-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
}
.toggle-on .toggle-thumb {
background: white;
transform: translateX(18px);
}
.toggle-label-text { font-size: 0.82rem; }
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
/* State strip */
.strip {
margin: 0 -1.6rem;
padding: 1rem 1.6rem;
border-top: 1px solid var(--color-border);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
transparent
);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
position: relative;
}
.strip[data-tone="citrus"]::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 10%,
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
transparent 90%
);
animation: aurora-shimmer 4s linear infinite;
}
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
margin-top: 0.45rem;
flex-shrink: 0;
}
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.strip-state {
font-family: var(--font-display);
font-style: italic;
font-size: 0.95rem;
letter-spacing: -0.01em;
color: var(--color-foreground);
}
.strip-meta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.74rem;
color: var(--color-muted-foreground);
}
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
.arrow { color: var(--color-muted-foreground); }
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
.sep { opacity: 0.5; }
.rel-time { color: var(--color-foreground); }
.strip-error {
font-size: 0.72rem;
color: var(--color-coral, #d27a7a);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-test-ok {
font-size: 0.72rem;
color: var(--color-mint, #6fcfa6);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
.strip-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.55rem;
background: var(--color-input-bg);
font-size: 0.76rem;
color: var(--color-foreground);
cursor: pointer;
text-decoration: none;
transition: background 0.18s, border-color 0.18s, transform 0.18s;
}
.strip-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
border-color: var(--color-primary);
}
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.strip-btn-primary {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
}
/* The CTA — high-visibility when an update is available. */
.strip-btn-cta {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
color: var(--color-foreground);
font-weight: 500;
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
}
.strip-btn-cta:hover {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
}
.mono { font-family: var(--font-mono); }
@keyframes aurora-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.strip[data-tone="citrus"]::before { animation: none; }
.strip-btn { transition: none; }
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 0.95rem 0;
}
.row-label { padding-top: 0; }
.repo-grid { grid-template-columns: 1fr; }
.strip { flex-direction: column; align-items: stretch; }
.strip-actions { justify-content: stretch; }
.strip-btn { flex: 1; justify-content: center; }
}
</style>
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
@@ -81,6 +82,19 @@
tone: SEVERITY_TONE[lvl] ?? 'mint',
});
const rs = releaseStatusCache.value;
if (rs) {
if (rs.provider === 'disabled') {
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
} else if (rs.error && rs.error !== 'provider_changed') {
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
} else if (rs.update_available && rs.latest) {
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
} else if (rs.latest) {
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
}
}
return out;
});
</script>
+57 -2
View File
@@ -15,6 +15,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -94,6 +95,53 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
function targetTiles(target: NotificationTarget): MetaTile[] {
const tiles: MetaTile[] = [];
// Type tile — useful when the "all types" filter is active and rows
// from multiple types appear side-by-side. The receivers count is
// already shown inside the `target-summary` button, so we don't repeat
// it as a tile.
tiles.push({
icon: TYPE_ICONS[target.type] || 'mdiTarget',
label: target.type,
tone: 'lavender',
mono: true,
});
const botName = getBotName(target);
if (botName) {
tiles.push({
icon: 'mdiRobot',
label: botName,
tone: 'sky',
});
}
// Telegram targets expose a chat label in config — surface it so the
// row reads "Telegram · @bot · Family chat" without expanding.
const cfg = (target.config || {}) as Record<string, any>;
if (target.type === 'telegram' && cfg.chat_id) {
tiles.push({
icon: 'mdiChat',
label: String(cfg.chat_id),
tone: 'orchid',
mono: true,
});
}
// Webhook target — show host
if (target.type === 'webhook' && cfg.url) {
let host = String(cfg.url);
try { host = new URL(host).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiLinkVariant',
label: host,
hint: String(cfg.url),
href: String(cfg.url),
tone: 'orchid',
mono: true,
});
}
return tiles;
}
// ── Derived state ──
let allTargets = $derived(targetsCache.items);
@@ -660,7 +708,7 @@
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<button
type="button"
class="target-summary"
@@ -682,6 +730,7 @@
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
{/if}
</button>
<MetaStrip tiles={targetTiles(target)} />
<div class="flex items-center gap-1 shrink-0">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
@@ -765,7 +814,7 @@
}
.target-summary {
flex: 1;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
@@ -780,6 +829,12 @@
border-radius: 8px;
transition: background 0.15s ease;
}
@media (min-width: 1024px) {
.target-summary {
flex: 0 1 auto;
max-width: 32rem;
}
}
.target-summary:hover {
background: var(--color-glass-strong);
}
@@ -28,6 +28,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { getDescriptor } from '$lib/providers';
import type { TemplateConfig } from '$lib/types';
@@ -426,6 +427,45 @@
setTimeout(() => refreshAllPreviews(), 100);
}
function templateConfigTiles(config: TemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
// Locale coverage — count unique locales present across all slots
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
@@ -627,24 +667,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={templateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -26,6 +26,7 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
@@ -238,6 +239,38 @@
window.history.replaceState(null, '', cleanUrl);
}
function trackingConfigTiles(config: Record<string, any>): MetaTile[] {
const tiles: MetaTile[] = [];
const desc = getDescriptor(config.provider_type);
const events = (desc?.eventFields ?? []).filter(f => config[f.key]);
tiles.push({
icon: 'mdiPulse',
value: String(events.length),
label: t('trackingConfig.eventTracking'),
hint: events.map(f => t(f.label)).join(', ') || undefined,
tone: events.length > 0 ? 'lavender' : 'default',
});
if (config.periodic_enabled) {
tiles.push({ icon: 'mdiTimerSyncOutline', label: t('trackingConfig.periodic'), tone: 'mint' });
}
if (config.scheduled_enabled) {
tiles.push({ icon: 'mdiCalendarClock', label: t('trackingConfig.scheduled'), tone: 'sky' });
}
if (config.memory_enabled) {
tiles.push({ icon: 'mdiHistory', label: t('trackingConfig.memory'), tone: 'orchid' });
}
if (config.quiet_hours_start && config.quiet_hours_end) {
tiles.push({
icon: 'mdiWeatherNight',
label: `${config.quiet_hours_start}${config.quiet_hours_end}`,
hint: t('trackingConfig.quietHoursStart'),
tone: 'citrus',
mono: true,
});
}
return tiles;
}
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
function edit(c: any) {
form = { ...defaultForm(), ...c };
@@ -448,25 +481,26 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
{@const desc = getDescriptor(config.provider_type)}
<Card hover entityId={config.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{config.provider_type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={trackingConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
+33 -6
View File
@@ -14,6 +14,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
@@ -87,6 +88,31 @@
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
@@ -133,15 +159,16 @@
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />