Files
notify-bridge/frontend/src/routes/notification-trackers/LinkedTargetsSection.svelte
T
alexei.dolgolyov 711f218622 fix(redesign): a11y, mobile, perf polish for production push
Comprehensive pre-production sweep across the Aurora redesign — drives
svelte-check to 0 errors / 0 warnings (was 61) without changing visual
intent. Highlights:

- Mobile: hero title shrinks at 480px, signal-list stacks timestamp
  under sentence below 640px, sidebar icon buttons bumped to 40x40
- Light theme: muted-foreground darkened to #3a3560 to clear WCAG AA
  on glass surfaces and the modal close button
- Perf: topbar backdrop-filter 28→14px, mobile-more sheet 28→12px to
  cut concurrent blur layers on mid-tier mobile
- a11y: prefers-reduced-motion mute for aurora drift / pulses /
  shimmer / stagger; aria-label on every icon-only button;
  aria-describedby on Hint; combobox/listbox/aria-activedescendant on
  SearchPalette; modal dialog tabindex; 47 label-without-control
  warnings across 14 form pages cleaned up via for=/id= or label→div
- Dashboard derived state split into topology- vs status-bound layers
  so polling no longer re-runs the full provider/wires computation
- Mobile bottom nav derived from baseNavEntries by key lookup so
  adding a top-level nav entry keeps the two trees in sync
- Bug: template-configs page now respects the global provider filter
  for both the count meter and the type pill (was reading the
  unfiltered cache)
- Misc: portal EventChart tooltip and switch its swatches to Aurora
  tokens; CollapsibleSlot warning state uses warning-fg/-bg tokens
  instead of #d97706; Hint z-index 99999→9999; element refs across
  Modal/EntitySelect/MultiEntitySelect/SearchPalette/IconGridSelect/
  Hint/targets converted to \$state for reactivity; 4 dead
  .topbar-cta selectors removed
2026-04-25 14:41:12 +03:00

159 lines
6.6 KiB
Svelte

<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
const TARGET_TYPE_ICONS: Record<string, string> = {
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix', broadcast: 'mdiBullhorn',
};
interface Props {
tracker: Tracker;
trackingConfigs: TrackingConfig[];
templateConfigs: TemplateConfig[];
unlinkedTargets: NotificationTarget[];
newLinkTargetId: number;
newLinkTrackingConfigId: number;
newLinkTemplateConfigId: number;
addingTarget: boolean;
ttTesting: Record<string, string>;
configsForTracker: (configs: (TrackingConfig | TemplateConfig)[]) => any[];
onupdateLink: (tt: any, field: string, value: any) => void;
onremoveLink: (ttId: number) => void;
onaddLink: () => void;
onopenTestMenu: (ttId: number, event: MouseEvent) => void;
onchangeNewTarget: (value: number) => void;
onchangeNewTrackingConfig: (value: number) => void;
onchangeNewTemplateConfig: (value: number) => void;
}
let {
tracker,
trackingConfigs,
templateConfigs,
unlinkedTargets,
newLinkTargetId,
newLinkTrackingConfigId,
newLinkTemplateConfigId,
addingTarget,
ttTesting,
configsForTracker,
onupdateLink,
onremoveLink,
onaddLink,
onopenTestMenu,
onchangeNewTarget,
onchangeNewTrackingConfig,
onchangeNewTemplateConfig,
}: Props = $props();
let expandedTt = $state<number | null>(null);
function toItems(configs: any[]): EntityItem[] {
return configsForTracker(configs).map(c => ({
value: c.id,
label: c.name,
icon: c.icon || '',
}));
}
function configName(configs: any[], id: number | null): string {
if (!id) return '';
const c = configs.find((x: any) => x.id === id);
return c?.name || '';
}
const trackingConfigItems = $derived(toItems(trackingConfigs));
const templateConfigItems = $derived(toItems(templateConfigs));
const linkedTargetIds = $derived(new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id)));
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
value: tgt.id,
label: tgt.name,
icon: tgt.icon || TARGET_TYPE_ICONS[tgt.type] || 'mdiTarget',
desc: tgt.type,
disabled: linkedTargetIds.has(tgt.id),
disabledHint: linkedTargetIds.has(tgt.id) ? t('notificationTracker.alreadyLinked') : undefined,
})));
</script>
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-1" in:slide>
{#if (tracker.tracker_targets || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
{:else}
{#each tracker.tracker_targets as tt (tt.id)}
{@const isExpanded = expandedTt === tt.id}
<div class="rounded-md bg-[var(--color-muted)]/30 overflow-hidden">
<!-- Header row — always visible -->
<div class="flex items-center justify-between text-sm px-2.5 py-1.5">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || TARGET_TYPE_ICONS[tt.target_type ?? ''] || 'mdiTarget'} size={16} /></span>
<button type="button" class="flex items-center gap-1 hover:text-[var(--color-primary)] transition-colors cursor-pointer"
onclick={() => expandedTt = isExpanded ? null : tt.id}>
<span class="font-medium truncate">{tt.target_name || `Target #${tt.target_id}`}</span>
<MdiIcon name={isExpanded ? 'mdiChevronUp' : 'mdiChevronDown'} size={14} />
</button>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
{/if}
<!-- Show overridden config badges when collapsed -->
{#if !isExpanded}
{#if tt.tracking_config_id}
<CrossLink href="/tracking-configs" icon="mdiCog" label={configName(trackingConfigs, tt.tracking_config_id)} entityId={tt.tracking_config_id} />
{/if}
{#if tt.template_config_id}
<CrossLink href="/template-configs" icon="mdiFileDocumentEdit" label={configName(templateConfigs, tt.template_config_id)} entityId={tt.template_config_id} />
{/if}
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
onclick={() => onremoveLink(tt.id)} variant="danger" />
</div>
</div>
<!-- Expanded config selectors -->
{#if isExpanded}
<div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}>
<div class="grid grid-cols-2 gap-2">
<div>
<div class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</div>
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div>
<div>
<div class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</div>
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
</div>
</div>
</div>
{/if}
</div>
{/each}
{/if}
<!-- Add target -->
{#if unlinkedTargets.length > 0}
<div class="mt-1">
<EntitySelect items={targetItems} value={null}
placeholder={"+ " + t('notificationTracker.addTarget')} size="sm"
onselect={(v) => { onchangeNewTarget(Number(v) || 0); setTimeout(onaddLink, 0); }} />
</div>
{/if}
</div>