Files
notify-bridge/frontend/src/routes/targets/TargetForm.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

192 lines
8.8 KiB
Svelte

<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
import Button from '$lib/components/Button.svelte';
interface Props {
form: {
name: string;
icon: string;
bot_id: number;
bot_token: string;
max_media_to_send: number;
max_media_per_group: number;
media_delay: number;
max_asset_size: number;
disable_url_preview: boolean;
send_large_photos_as_documents: boolean;
ai_captions: boolean;
chat_action: string;
username: string;
server_url: string;
auth_token: string;
matrix_bot_id: number;
email_bot_id: number;
child_target_ids: number[];
};
formType: string;
activeType: string | null;
typeGridItems: GridItem[];
telegramBotItems: EntityItem[];
emailBotItems: EntityItem[];
matrixBotItems: EntityItem[];
chatActionItems: GridItem[];
broadcastChildItems?: { value: number; label: string; icon: string; desc: string }[];
telegramBotCount: number;
emailBotCount: number;
matrixBotCount: number;
editing: number | null;
submitting: boolean;
error: string;
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
}
let {
form = $bindable(),
formType = $bindable(),
activeType,
typeGridItems,
telegramBotItems,
emailBotItems,
matrixBotItems,
chatActionItems,
broadcastChildItems = [],
telegramBotCount,
emailBotCount,
matrixBotCount,
editing,
submitting,
error,
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
}: Props = $props();
</script>
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={onsave} class="space-y-4">
{#if !activeType}
<div>
<div class="block text-sm font-medium mb-1">{t('targets.type')}</div>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<div class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</div>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p>
{/if}
</div>
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={ontoggleTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<div class="block text-xs mb-1">{t('targets.chatAction')}</div>
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<div class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</div>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p>
{/if}
</div>
{:else if formType === 'matrix'}
<div>
<div class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</div>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
{/if}
</div>
{:else if formType === 'broadcast'}
{@const childIds = (form.child_target_ids || []).map(String)}
<div>
<div class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</div>
<MultiEntitySelect
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
values={childIds}
onchange={(vals) => form.child_target_ids = vals.map(Number)}
placeholder={t('targets.selectChildTargets')}
/>
{#if broadcastChildItems?.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('targets.noChildTargetsAvailable')}</p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/if}
<Button type="submit" disabled={submitting}>{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</Button>
</form>
</Card>
</div>