Files
notify-bridge/frontend/src/routes/providers/+page.svelte
T
alexei.dolgolyov 9733e5c122 feat(redesign): subpage hero header + iconpicker portal + tighter gaps
Three threads bundled:

- PageHeader.svelte upgraded to a glass-card subpage hero matching
  the dashboard hero language, scaled down. New optional props (all
  backward-compatible — old callers keep working): emphasis (italic
  gradient word appended to title), crumb (uppercase mono kicker),
  count + countLabel (right-side mono meter), pills (status chips
  with tones: mint / sky / orchid / coral / citrus / primary).
- Providers page wired up first as the test surface: pulls live
  online/offline/checking counts from the existing health probe and
  shows a type-count pill. Locale keys added (en + ru) for the new
  copy ('Service / providers' wordmark, longer description).
- IconPicker dropdown was suffering the same backdrop-filter
  containing-block bug as IconGridSelect — repositioned popups
  rendered inside any glass form panel got clipped or floated to
  the bottom of the page. Now portals to <body> via the shared
  $lib/portal action and uses Aurora glass styling end-to-end
  (solid surface, gradient-active cell, glass-strong search input).
- Layout gaps tightened to match the mockup:
    * sidebar→content horizontal gap is now 18px flat (was 50px:
      the 18px shell-gap PLUS another 32px wrapper padding on each
      child of main). Dropped px-4/md:px-8 from the topbar wrapper
      and the per-page content wrapper — main's children sit flush
      at the column boundary.
    * topbar→content vertical gap reduced to 12px (was 16px / pt-4).

Build clean, 0 errors.
2026-04-25 02:15:34 +03:00

338 lines
14 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems, providerDefaultIcon, webhookAuthModeItems } from '$lib/grid-items';
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
import type { ServiceProvider } from '$lib/types';
let allProviders = $derived(providersCache.items);
let filterText = $state('');
let providers = $derived(allProviders.filter(p =>
(!filterText || p.name.toLowerCase().includes(filterText.toLowerCase()) || p.type.toLowerCase().includes(filterText.toLowerCase())) &&
(!globalProviderFilter.id || p.id === globalProviderFilter.id)
));
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state(buildProviderFormDefaults());
let nameManuallyEdited = $state(false);
let error = $state('');
let loadError = $state('');
let submitting = $state(false);
let loaded = $state(false);
let confirmDelete = $state<ServiceProvider | null>(null);
let descriptor = $derived(getDescriptor(form.type));
// Auto-update name when provider type changes (unless user manually edited)
$effect(() => {
const desc = getDescriptor(form.type);
if (!nameManuallyEdited && !editing && desc) {
form.name = desc.defaultName;
}
});
let health = $state<Record<number, boolean | null>>({});
// Status pill row for the page header — derived from health probes.
const headerPills = $derived.by(() => {
const onlineCount = Object.values(health).filter(v => v === true).length;
const offlineCount = Object.values(health).filter(v => v === false).length;
const checkingCount = Math.max(0, providers.length - onlineCount - offlineCount);
const typeCount = new Set(providers.map(p => p.type)).size;
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'coral' | 'citrus' }> = [];
if (onlineCount > 0) pills.push({ label: `${onlineCount} ${t('providers.online')}`, tone: 'mint' });
if (offlineCount > 0) pills.push({ label: `${offlineCount} ${t('providers.offline')}`, tone: 'coral' });
if (checkingCount > 0 && providers.length > 0) pills.push({ label: `${checkingCount} ${t('providers.checking')}`, tone: 'citrus' });
if (typeCount > 0) pills.push({ label: `${typeCount} ${typeCount === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
onMount(load);
async function load() {
try {
await providersCache.fetch(true);
loadError = '';
} catch (err: any) {
loadError = err.message || t('providers.loadError');
} finally { loaded = true; highlightFromUrl(); }
// Ping all providers in background (use unfiltered list)
for (const p of allProviders) {
health = { ...health, [p.id]: null };
api(`/providers/${p.id}/test`, { method: 'POST' })
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
.catch(() => { health = { ...health, [p.id]: false }; });
}
}
function openNew() {
form = buildProviderFormDefaults();
nameManuallyEdited = false;
editing = null; showForm = true;
}
function edit(p: any) {
const cfg = p.config || {};
const base = buildProviderFormDefaults();
const desc = getDescriptor(p.type);
// Populate common fields
base.name = p.name;
base.type = p.type;
base.icon = p.icon || '';
base.url = cfg.url || '';
// Populate provider-specific fields from config using configKey mapping
if (desc) {
for (const field of desc.configFields) {
const cfgKey = field.configKey || field.key;
// Secrets (password fields) are blank on edit; non-secret fields load from config
if (field.type === 'password') {
base[field.key] = '';
} else {
base[field.key] = cfg[cfgKey] ?? field.defaultValue ?? '';
}
}
}
form = base;
nameManuallyEdited = true;
editing = p.id; showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
const desc = getDescriptor(form.type);
if (!desc) {
error = `Unknown provider type: ${form.type}`;
snackError(error); submitting = false; return;
}
const { config, error: buildError } = desc.buildConfig(form, !!editing);
if (buildError) {
error = t(buildError);
snackError(error); submitting = false; return;
}
if (editing) {
const existing = providers.find(p => p.id === editing)?.config || {};
const hasConfigChange = desc.hasConfigChanged(form, existing);
const body: any = { name: form.name, icon: form.icon };
if (hasConfigChange) body.config = config;
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) });
}
showForm = false; editing = null; providersCache.invalidate(); await load();
snackSuccess(t('snack.providerSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
submitting = false;
}
function startDelete(provider: any) { confirmDelete = provider; }
let blockedBy = $state<BlockedByDetail | null>(null);
async function doDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
}
</script>
<PageHeader
title={t('providers.title')}
emphasis={t('providers.titleEmphasis')}
description={t('providers.description')}
crumb="Service · Connections"
count={providers.length}
countLabel={t('dashboard.providersShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('providers.cancel') : t('providers.addProvider')}
</Button>
</PageHeader>
{#if !loaded}
<Loading />
{:else}
{#if loadError}
<Card class="mb-6">
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={18} />
{loadError}
</div>
</Card>
{/if}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
<ErrorBanner message={error} />
<form onsubmit={save} class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label>
{#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
{:else}
<p class="text-sm text-[var(--color-muted-foreground)]">{form.type}</p>
{/if}
</div>
<div>
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if descriptor?.hasUrl}
<div>
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" bind:value={form.url} required placeholder={descriptor.urlPlaceholder || t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#each descriptor?.configFields ?? [] as field (field.key)}
<div>
<label for="prv-{field.key}" class="block text-sm font-medium mb-1">
{t(editing && field.editLabel ? field.editLabel : field.label)}
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
</label>
{#if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else if field.type === 'number'}
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || (field.required === 'create-only' && !editing)}
placeholder={field.placeholder || ''}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if field.hint}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t(field.hint)}</p>
{/if}
</div>
{/each}
{#if descriptor?.webhookUrlPattern && editing}
<div class="bg-[var(--color-muted)] rounded-md p-3">
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div>
{/if}
<Button type="submit" disabled={submitting}>
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
</Button>
</form>
</Card>
</div>
{/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>
{/if}
{#if allProviders.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiServer" message={t('providers.noProviders')} />
</Card>
{:else if providers.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 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>
{#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}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
</div>
</div>
{#if provDesc?.payloadHistory && !showForm}
<WebhookPayloadHistory providerId={provider.id} />
{/if}
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.health-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.3s ease;
}
.health-dot.online {
background: var(--color-success-fg);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-success-fg) 40%, transparent);
}
.health-dot.offline {
background: var(--color-error-fg);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-error-fg) 30%, transparent);
}
.health-dot.checking {
background: var(--color-warning-fg);
animation: pulseCheck 1.5s ease-in-out infinite;
}
@keyframes pulseCheck {
0%, 100% { box-shadow: 0 0 4px color-mix(in srgb, var(--color-warning-fg) 30%, transparent); }
50% { box-shadow: 0 0 12px color-mix(in srgb, var(--color-warning-fg) 60%, transparent); }
}
</style>