refactor: provider descriptor registry — eliminate provider-specific hardcoding
Replace all if/else chains keyed on provider type strings with a descriptor-driven architecture. Each provider type (immich, gitea, planka, scheduler, nut, google_photos) has a descriptor in frontend/src/lib/providers/ that declares config fields, event tracking fields, collection metadata, validation, and hooks. Components now use getDescriptor(type) and render dynamically. Dashboard provider card shows provider name + type when global filter is active. Grid-items derived from registry.
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
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 type { ServiceProvider } from '$lib/types';
|
||||
|
||||
let allProviders = $derived(providersCache.items);
|
||||
@@ -27,7 +28,7 @@
|
||||
));
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' });
|
||||
let form = $state(buildProviderFormDefaults());
|
||||
let nameManuallyEdited = $state(false);
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
@@ -35,15 +36,13 @@
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<ServiceProvider | null>(null);
|
||||
|
||||
const providerDefaultNames: Record<string, string> = {
|
||||
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', nut: 'NUT',
|
||||
};
|
||||
let descriptor = $derived(getDescriptor(form.type));
|
||||
|
||||
// Auto-update name when provider type changes (unless user manually edited)
|
||||
$effect(() => {
|
||||
const type = form.type;
|
||||
if (!nameManuallyEdited && !editing) {
|
||||
form.name = providerDefaultNames[type] || type;
|
||||
const desc = getDescriptor(form.type);
|
||||
if (!nameManuallyEdited && !editing && desc) {
|
||||
form.name = desc.defaultName;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,19 +66,33 @@
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' };
|
||||
form = buildProviderFormDefaults();
|
||||
nameManuallyEdited = false;
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
|
||||
function edit(p: any) {
|
||||
const cfg = p.config || {};
|
||||
form = {
|
||||
name: p.name, type: p.type, url: cfg.url || '',
|
||||
api_key: '', api_token: '', webhook_secret: '',
|
||||
external_domain: cfg.external_domain || '', icon: p.icon || '',
|
||||
nut_host: cfg.host || '', nut_port: cfg.port || 3493,
|
||||
nut_username: '', nut_password: '',
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -87,40 +100,19 @@
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
let config: any;
|
||||
if (form.type === 'nut') {
|
||||
config = { host: form.nut_host, port: form.nut_port || 3493 };
|
||||
if (form.nut_username) config.username = form.nut_username;
|
||||
if (form.nut_password) config.password = form.nut_password;
|
||||
} else {
|
||||
config = { url: form.url };
|
||||
}
|
||||
if (form.type === 'immich') {
|
||||
if (form.api_key) config.api_key = form.api_key;
|
||||
if (form.external_domain) config.external_domain = form.external_domain;
|
||||
if (!editing) config.api_key = form.api_key;
|
||||
} else if (form.type === 'gitea') {
|
||||
if (form.api_token) config.api_token = form.api_token;
|
||||
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
|
||||
if (!editing && !form.webhook_secret) {
|
||||
error = t('providers.webhookSecretRequired');
|
||||
snackError(error); submitting = false; return;
|
||||
}
|
||||
} else if (form.type === 'planka') {
|
||||
if (form.api_key) config.api_key = form.api_key;
|
||||
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
|
||||
if (!editing && !form.webhook_secret) {
|
||||
error = t('providers.webhookSecretRequired');
|
||||
snackError(error); submitting = false; return;
|
||||
}
|
||||
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) {
|
||||
// Only send config if user changed a config field (secrets are blank on edit)
|
||||
const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') ||
|
||||
(form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) ||
|
||||
(form.type === 'gitea' && (form.api_token || form.webhook_secret)) ||
|
||||
(form.type === 'planka' && (form.api_key || form.webhook_secret)) ||
|
||||
(form.type === 'nut');
|
||||
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) });
|
||||
@@ -185,77 +177,40 @@
|
||||
<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 form.type !== 'scheduler' && form.type !== 'nut'}
|
||||
{#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={form.type === 'gitea' ? 'https://gitea.example.com' : form.type === 'planka' ? 'https://planka.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<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}
|
||||
{#if form.type === 'immich'}
|
||||
{#each descriptor?.configFields ?? [] as field (field.key)}
|
||||
<div>
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
|
||||
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<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 === '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>
|
||||
<div>
|
||||
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{:else if form.type === 'gitea'}
|
||||
<div>
|
||||
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
|
||||
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookSecretHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-token" class="block text-sm font-medium mb-1">{t('providers.apiToken')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-token" bind:value={form.api_token} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.apiTokenHint')}</p>
|
||||
</div>
|
||||
{#if editing}
|
||||
{/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">/api/webhooks/gitea/{editing}</code>
|
||||
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{id}', String(editing))}</code>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if form.type === 'planka'}
|
||||
<div>
|
||||
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
|
||||
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookSecretHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-key" bind:value={form.api_key} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaApiKeyHint')}</p>
|
||||
</div>
|
||||
{#if 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">/api/webhooks/planka/{editing}</code>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookUrlHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if form.type === 'nut'}
|
||||
<div>
|
||||
<label for="prv-nut-host" class="block text-sm font-medium mb-1">{t('providers.nutHost')}</label>
|
||||
<input id="prv-nut-host" bind:value={form.nut_host} required placeholder={t('providers.nutHostPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-nut-port" class="block text-sm font-medium mb-1">{t('providers.nutPort')}</label>
|
||||
<input id="prv-nut-port" bind:value={form.nut_port} type="number" min="1" max="65535" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-nut-user" class="block text-sm font-medium mb-1">{t('providers.nutUsername')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-nut-user" bind:value={form.nut_username} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutUsernameHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-nut-pass" class="block text-sm font-medium mb-1">{t('providers.nutPassword')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-nut-pass" bind:value={form.nut_password} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutPasswordHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
|
||||
@@ -283,6 +238,7 @@
|
||||
{: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">
|
||||
@@ -298,10 +254,8 @@
|
||||
{: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 provider.type === 'gitea'}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/gitea/{provider.id}</span></p>
|
||||
{:else if provider.type === 'planka'}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/planka/{provider.id}</span></p>
|
||||
{#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('{id}', String(provider.id))}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user