Files
notify-bridge/frontend/src/routes/providers/new/+page.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

137 lines
5.6 KiB
Svelte

<script lang="ts">
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { goto } from '$app/navigation';
import { providerTypeItems, webhookAuthModeItems } from '$lib/grid-items';
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
let form = $state(buildProviderFormDefaults());
let error = $state('');
let testing = $state(false);
let saving = $state(false);
let descriptor = $derived(getDescriptor(form.type));
async function testAndSave() {
const desc = descriptor;
if (!desc) { error = t('providers.selectType'); return; }
const { config, error: buildError } = desc.buildConfig(form, false);
if (buildError) { error = t(buildError); snackError(error); return; }
testing = true; error = '';
let createdId: number | null = null;
try {
const provider = await api('/providers', {
method: 'POST',
body: JSON.stringify({ type: form.type, name: form.name || desc.defaultName, icon: form.icon, config }),
});
createdId = provider.id;
const result = await api(`/providers/${provider.id}/test`, { method: 'POST' });
if (!result.ok) {
await api(`/providers/${provider.id}`, { method: 'DELETE' }).catch(() => {});
createdId = null;
error = result.message || t('providers.testFailed');
snackError(error);
} else {
snackSuccess(t('snack.providerSaved'));
goto('/providers');
}
} catch (e: any) {
if (createdId) await api(`/providers/${createdId}`, { method: 'DELETE' }).catch(() => {});
error = e.message || t('providers.testFailed'); snackError(error);
}
finally { testing = false; }
}
async function saveWithoutTest() {
const desc = descriptor;
if (!desc) { error = t('providers.selectType'); return; }
const { config, error: buildError } = desc.buildConfig(form, false);
if (buildError) { error = t(buildError); snackError(error); return; }
saving = true; error = '';
try {
await api('/providers', {
method: 'POST',
body: JSON.stringify({ type: form.type, name: form.name || desc.defaultName, icon: form.icon, config }),
});
snackSuccess(t('snack.providerSaved'));
goto('/providers');
} catch (e: any) { error = e.message || t('common.saveFailed'); snackError(error); }
finally { saving = false; }
}
</script>
<div class="mb-4">
<a href="/providers" class="text-sm text-[var(--color-muted-foreground)] hover:underline">{t('providers.title')}</a>
</div>
<h2 class="text-xl font-semibold mb-8">{t('providers.addProvider')}</h2>
<Card>
<div class="space-y-4">
<div>
<div class="block text-sm font-medium mb-1">{t('providers.type')}</div>
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
</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} placeholder={descriptor?.defaultName || ''} 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" type="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(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'}
placeholder={field.placeholder || ''} autocomplete="off"
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}
<ErrorBanner message={error} />
<div class="flex gap-3 pt-2">
<Button onclick={testAndSave} disabled={testing || saving}>
{testing ? t('providers.connecting') : t('providers.testAndSave')}
</Button>
<Button variant="secondary" onclick={saveWithoutTest} disabled={testing || saving}>
{saving ? t('common.loading') : t('providers.saveWithoutTest')}
</Button>
<Button variant="secondary" href="/providers">
{t('common.cancel')}
</Button>
</div>
</div>
</Card>