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:
@@ -26,9 +26,12 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
||||
- Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py`
|
||||
- Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs)
|
||||
- Template variable definitions in `packages/server/src/notify_bridge_server/api/template_configs.py` (`get_template_variables()`)
|
||||
8. **No provider-specific hardcoding** — UI labels, icons, form defaults, and feature checks MUST be provider-agnostic. NEVER hardcode a specific provider type (e.g. `'immich'`) where multiple providers could appear:
|
||||
- Form defaults: use `provider_type: ''` (empty), not `'immich'`
|
||||
- Collection labels: use the `collectionMeta` lookup in `TrackerForm.svelte`, not hardcoded "Albums"
|
||||
8. **No provider-specific hardcoding** — ALL provider-specific UI logic lives in **provider descriptors** (`frontend/src/lib/providers/`). NEVER add `if (type === 'xyz')` in components.
|
||||
- Form fields, validation, config building → defined in the descriptor's `configFields` / `buildConfig` / `hasConfigChanged`
|
||||
- Event tracking checkboxes → `eventFields`; extra controls → `extraTrackingFields`; feature sections (periodic/scheduled/memory) → `featureSections`
|
||||
- Collection labels/icons → `collectionMeta`; webhook URL display → `webhookUrlPattern`
|
||||
- Pre-save hooks (e.g. shared-link validation) → `onBeforeSave`
|
||||
- Components use `getDescriptor(type)` and render dynamically from the descriptor
|
||||
- Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'`
|
||||
- Provider-specific API calls (e.g. `/albums/.../shared-links`): guard with a provider type check
|
||||
- Template variable helpers: ALL provider types must have entries in `get_template_variables()`
|
||||
9. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`.
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
/**
|
||||
* Shared IconGridSelect item definitions used across multiple pages.
|
||||
* Keeps grid item arrays DRY and consistent.
|
||||
*
|
||||
* Provider-specific items (type selector, filter, icons) are derived
|
||||
* from the provider descriptor registry — see lib/providers/.
|
||||
*/
|
||||
|
||||
import { t } from '$lib/i18n';
|
||||
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||
|
||||
/** Default icon for each provider type. Use instead of hardcoded 'mdiServer'. */
|
||||
const PROVIDER_TYPE_ICONS: Record<string, string> = {
|
||||
immich: 'mdiImageMultiple',
|
||||
gitea: 'mdiGit',
|
||||
planka: 'mdiViewDashboard',
|
||||
scheduler: 'mdiClockOutline',
|
||||
nut: 'mdiBatteryCharging80',
|
||||
};
|
||||
import { allDescriptors, getDescriptor } from '$lib/providers';
|
||||
|
||||
/** Get the default icon for a provider, falling back by type then generic. */
|
||||
export function providerDefaultIcon(provider: { icon?: string; type?: string }): string {
|
||||
if (provider.icon) return provider.icon;
|
||||
if (provider.type && PROVIDER_TYPE_ICONS[provider.type]) return PROVIDER_TYPE_ICONS[provider.type];
|
||||
if (provider.type) {
|
||||
const desc = getDescriptor(provider.type);
|
||||
if (desc) return desc.icon;
|
||||
}
|
||||
return 'mdiServer';
|
||||
}
|
||||
|
||||
@@ -111,23 +109,24 @@ export const previewTargetTypeItems = (): GridItem[] => [
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook'), desc: t('gridDesc.previewWebhook') },
|
||||
];
|
||||
|
||||
// --- Provider type filter (with "All" option) ---
|
||||
// --- Provider type items (derived from descriptor registry) ---
|
||||
|
||||
/** Convert snake_case type to PascalCase i18n suffix: "google_photos" → "GooglePhotos" */
|
||||
function typeToKey(type: string): string {
|
||||
return type.replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function descriptorToGridItem(d: { type: string; icon: string }): GridItem {
|
||||
const key = typeToKey(d.type);
|
||||
return { value: d.type, icon: d.icon, label: t(`providers.type${key}`), desc: t(`gridDesc.provider${key}`) };
|
||||
}
|
||||
|
||||
/** Provider type filter with "All types" option. */
|
||||
export const providerTypeFilterItems = (): GridItem[] => [
|
||||
{ value: '', icon: 'mdiFilterOff', label: t('common.allTypes'), desc: t('gridDesc.allEvents') },
|
||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') },
|
||||
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
||||
{ value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
||||
{ value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
||||
{ value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') },
|
||||
...allDescriptors().map(descriptorToGridItem),
|
||||
];
|
||||
|
||||
// --- Provider type ---
|
||||
|
||||
export const providerTypeItems = (): GridItem[] => [
|
||||
{ value: 'immich', icon: PROVIDER_TYPE_ICONS.immich, label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') },
|
||||
{ value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
||||
{ value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
||||
{ value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
||||
{ value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') },
|
||||
];
|
||||
/** Provider type selector (no "All" option). */
|
||||
export const providerTypeItems = (): GridItem[] =>
|
||||
allDescriptors().map(descriptorToGridItem);
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"repos_count": "repo(s)",
|
||||
"boards_count": "board(s)",
|
||||
"devices_count": "device(s)",
|
||||
"collections_count": "collection(s)",
|
||||
"every": "every",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
@@ -403,6 +404,7 @@
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default tracking",
|
||||
"noConfigs": "No tracking configs yet.",
|
||||
"unknownProviderType": "Unknown provider type",
|
||||
"eventTracking": "Event Tracking",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"repos_count": "репозиторий(ев)",
|
||||
"boards_count": "доска(ок)",
|
||||
"devices_count": "устройство(в)",
|
||||
"collections_count": "коллекция(й)",
|
||||
"every": "каждые",
|
||||
"trackImages": "Отслеживать фото",
|
||||
"trackVideos": "Отслеживать видео",
|
||||
@@ -403,6 +404,7 @@
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Основное отслеживание",
|
||||
"noConfigs": "Конфигураций отслеживания пока нет.",
|
||||
"unknownProviderType": "Неизвестный тип провайдера",
|
||||
"eventTracking": "Отслеживание событий",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const giteaDescriptor: ProviderDescriptor = {
|
||||
type: 'gitea',
|
||||
defaultName: 'Gitea',
|
||||
icon: 'mdiGit',
|
||||
hasUrl: true,
|
||||
urlPlaceholder: 'https://gitea.example.com',
|
||||
webhookBased: true,
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'webhook_secret', configKey: 'webhook_secret',
|
||||
label: 'providers.webhookSecret', editLabel: 'providers.webhookSecretKeep',
|
||||
type: 'password', required: 'create-only', hint: 'providers.webhookSecretHint',
|
||||
},
|
||||
{
|
||||
key: 'api_token', configKey: 'api_token',
|
||||
label: 'providers.apiToken',
|
||||
type: 'password', optional: true, hint: 'providers.apiTokenHint',
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, any> = { url: form.url };
|
||||
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) {
|
||||
return { config, error: 'providers.webhookSecretRequired' };
|
||||
}
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
return form.url !== (existing.url || '') ||
|
||||
!!form.api_token || !!form.webhook_secret;
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_push', label: 'trackingConfig.push', default: true },
|
||||
{ key: 'track_issue_opened', label: 'trackingConfig.issueOpened', default: true },
|
||||
{ key: 'track_issue_closed', label: 'trackingConfig.issueClosed', default: true },
|
||||
{ key: 'track_issue_commented', label: 'trackingConfig.issueCommented', default: false },
|
||||
{ key: 'track_pr_opened', label: 'trackingConfig.prOpened', default: true },
|
||||
{ key: 'track_pr_closed', label: 'trackingConfig.prClosed', default: true },
|
||||
{ key: 'track_pr_merged', label: 'trackingConfig.prMerged', default: true },
|
||||
{ key: 'track_pr_commented', label: 'trackingConfig.prCommented', default: false },
|
||||
{ key: 'track_release_published', label: 'trackingConfig.releasePublished', default: true },
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.repositories',
|
||||
icon: 'mdiGit',
|
||||
placeholder: 'notificationTracker.selectRepositories',
|
||||
countLabel: 'notificationTracker.repos_count',
|
||||
desc: () => '',
|
||||
},
|
||||
|
||||
webhookUrlPattern: '/api/webhooks/gitea/{id}',
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const googlePhotosDescriptor: ProviderDescriptor = {
|
||||
type: 'google_photos',
|
||||
defaultName: 'Google Photos',
|
||||
icon: 'mdiGoogle',
|
||||
hasUrl: false,
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'gp_client_id', configKey: 'client_id',
|
||||
label: 'providers.gpClientId', type: 'text',
|
||||
required: 'create-only', placeholder: '123456789.apps.googleusercontent.com',
|
||||
},
|
||||
{
|
||||
key: 'gp_client_secret', configKey: 'client_secret',
|
||||
label: 'providers.gpClientSecret', editLabel: 'providers.gpClientSecretKeep',
|
||||
type: 'password', required: 'create-only',
|
||||
},
|
||||
{
|
||||
key: 'gp_refresh_token', configKey: 'refresh_token',
|
||||
label: 'providers.gpRefreshToken', editLabel: 'providers.gpRefreshTokenKeep',
|
||||
type: 'password', required: 'create-only', hint: 'providers.gpRefreshTokenHint',
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, any> = {};
|
||||
if (form.gp_client_id) config.client_id = form.gp_client_id;
|
||||
if (form.gp_client_secret) config.client_secret = form.gp_client_secret;
|
||||
if (form.gp_refresh_token) config.refresh_token = form.gp_refresh_token;
|
||||
if (!editing && (!form.gp_client_id || !form.gp_client_secret || !form.gp_refresh_token)) {
|
||||
return { config, error: 'providers.gpAllFieldsRequired' };
|
||||
}
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
return form.gp_client_id !== (existing.client_id || '') ||
|
||||
!!form.gp_client_secret || !!form.gp_refresh_token;
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_assets_added', label: 'trackingConfig.assetsAdded', default: true },
|
||||
{ key: 'track_assets_removed', label: 'trackingConfig.assetsRemoved', default: false },
|
||||
{ key: 'track_collection_renamed', label: 'trackingConfig.albumRenamed', default: true },
|
||||
{ key: 'track_collection_deleted', label: 'trackingConfig.albumDeleted', default: true },
|
||||
{ key: 'track_sharing_changed', label: 'trackingConfig.sharingChanged', default: false },
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.albums',
|
||||
icon: 'mdiGoogle',
|
||||
placeholder: 'notificationTracker.selectAlbums',
|
||||
countLabel: 'notificationTracker.albums_count',
|
||||
desc: (col) => `${col.assetCount ?? col.asset_count ?? col.mediaItemsCount ?? 0} items`,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const immichDescriptor: ProviderDescriptor = {
|
||||
type: 'immich',
|
||||
defaultName: 'Immich',
|
||||
icon: 'mdiImageMultiple',
|
||||
hasUrl: true,
|
||||
urlPlaceholder: undefined, // uses generic i18n placeholder
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'api_key', configKey: 'api_key',
|
||||
label: 'providers.apiKey', editLabel: 'providers.apiKeyKeep',
|
||||
type: 'password', required: 'create-only',
|
||||
},
|
||||
{
|
||||
key: 'external_domain', configKey: 'external_domain',
|
||||
label: 'providers.externalDomain',
|
||||
type: 'text', optional: true, placeholder: 'https://photos.example.com',
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, any> = { url: form.url };
|
||||
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;
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
return form.url !== (existing.url || '') ||
|
||||
!!form.api_key ||
|
||||
form.external_domain !== (existing.external_domain || '');
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_assets_added', label: 'trackingConfig.assetsAdded', default: true },
|
||||
{ key: 'track_assets_removed', label: 'trackingConfig.assetsRemoved', default: false },
|
||||
{ key: 'track_collection_renamed', label: 'trackingConfig.albumRenamed', default: true },
|
||||
{ key: 'track_collection_deleted', label: 'trackingConfig.albumDeleted', default: true },
|
||||
{ key: 'track_sharing_changed', label: 'trackingConfig.sharingChanged', default: false },
|
||||
{ key: 'track_images', label: 'trackingConfig.trackImages', default: true },
|
||||
{ key: 'track_videos', label: 'trackingConfig.trackVideos', default: true },
|
||||
{ key: 'notify_favorites_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' },
|
||||
{ key: 'include_tags', label: 'trackingConfig.includePeople', default: true },
|
||||
{ key: 'include_asset_details', label: 'trackingConfig.includeDetails', default: false },
|
||||
],
|
||||
|
||||
extraTrackingFields: [
|
||||
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 5, hint: 'hints.maxAssets' },
|
||||
{ key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' },
|
||||
{ key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' },
|
||||
],
|
||||
|
||||
featureSections: [
|
||||
{
|
||||
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
|
||||
enabledField: 'periodic_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 },
|
||||
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input
|
||||
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
||||
enabledField: 'scheduled_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
|
||||
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' },
|
||||
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
||||
],
|
||||
checkboxes: [
|
||||
{ key: 'scheduled_favorite_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
||||
enabledField: 'memory_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
|
||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
|
||||
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' },
|
||||
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 },
|
||||
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 },
|
||||
],
|
||||
checkboxes: [
|
||||
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.albums',
|
||||
icon: 'mdiImageMultiple',
|
||||
placeholder: 'notificationTracker.selectAlbums',
|
||||
countLabel: 'notificationTracker.albums_count',
|
||||
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
||||
},
|
||||
|
||||
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
|
||||
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
||||
if (newIds.length === 0) return { proceed: true };
|
||||
|
||||
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
||||
const warnings: { id: string; name: string; issue: string }[] = [];
|
||||
|
||||
for (const albumId of newIds) {
|
||||
try {
|
||||
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
||||
if (!validLink) {
|
||||
const album = collections.find(c => c.id === albumId);
|
||||
const problematic = links.find((l) => l.is_expired || l.has_password);
|
||||
warnings.push({
|
||||
id: albumId,
|
||||
name: album?.albumName || album?.name || albumId,
|
||||
issue: problematic
|
||||
? (problematic.is_expired ? 'expired' : 'password-protected')
|
||||
: 'missing',
|
||||
});
|
||||
}
|
||||
} catch { /* shared-link check failed, proceed */ }
|
||||
}
|
||||
|
||||
if (warnings.length > 0) return { warnings, proceed: false };
|
||||
return { proceed: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Provider descriptor registry.
|
||||
*
|
||||
* Single entry point for all provider-specific UI logic.
|
||||
* Components call `getDescriptor(type)` instead of if/else chains.
|
||||
*/
|
||||
|
||||
import type { ProviderDescriptor } from './types';
|
||||
import { immichDescriptor } from './immich';
|
||||
import { giteaDescriptor } from './gitea';
|
||||
import { plankaDescriptor } from './planka';
|
||||
import { schedulerDescriptor } from './scheduler';
|
||||
import { nutDescriptor } from './nut';
|
||||
import { googlePhotosDescriptor } from './google-photos';
|
||||
|
||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['immich', immichDescriptor],
|
||||
['gitea', giteaDescriptor],
|
||||
['planka', plankaDescriptor],
|
||||
['scheduler', schedulerDescriptor],
|
||||
['nut', nutDescriptor],
|
||||
['google_photos', googlePhotosDescriptor],
|
||||
]);
|
||||
|
||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||
export function getDescriptor(type: string): ProviderDescriptor | null {
|
||||
return REGISTRY.get(type) ?? null;
|
||||
}
|
||||
|
||||
/** All registered descriptors (stable order). */
|
||||
export function allDescriptors(): ProviderDescriptor[] {
|
||||
return [...REGISTRY.values()];
|
||||
}
|
||||
|
||||
/** All registered provider type strings. */
|
||||
export function allProviderTypes(): string[] {
|
||||
return [...REGISTRY.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a default form state for the tracking config "Event tracking" fieldset.
|
||||
* Merges event field defaults from ALL providers so the form can load any
|
||||
* existing config without missing keys, but only the active provider's fields
|
||||
* are rendered.
|
||||
*/
|
||||
export function buildTrackingFormDefaults(): Record<string, any> {
|
||||
const defaults: Record<string, any> = {};
|
||||
for (const desc of REGISTRY.values()) {
|
||||
for (const field of desc.eventFields) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
for (const extra of desc.extraTrackingFields ?? []) {
|
||||
defaults[extra.key] = extra.defaultValue ?? '';
|
||||
}
|
||||
for (const section of desc.featureSections ?? []) {
|
||||
defaults[section.enabledField] = section.enabledDefault;
|
||||
for (const f of section.fields) {
|
||||
defaults[f.key] = f.defaultValue ?? '';
|
||||
}
|
||||
for (const cb of section.checkboxes ?? []) {
|
||||
defaults[cb.key] = cb.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a default flat form state for the provider config form.
|
||||
* All config fields from all providers are included with empty/default values.
|
||||
*/
|
||||
export function buildProviderFormDefaults(): Record<string, any> {
|
||||
const defaults: Record<string, any> = {
|
||||
name: '', type: '', url: '', icon: '',
|
||||
};
|
||||
for (const desc of REGISTRY.values()) {
|
||||
for (const field of desc.configFields) {
|
||||
defaults[field.key] = field.defaultValue ?? (field.type === 'number' ? 0 : '');
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export type { ProviderDescriptor, ConfigField, EventTrackingField, ExtraTrackingField, FeatureSection, CollectionMeta } from './types';
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const nutDescriptor: ProviderDescriptor = {
|
||||
type: 'nut',
|
||||
defaultName: 'NUT',
|
||||
icon: 'mdiBatteryCharging80',
|
||||
hasUrl: false,
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'nut_host', configKey: 'host',
|
||||
label: 'providers.nutHost', type: 'text',
|
||||
required: true, placeholder: '192.168.1.100 or ups.local',
|
||||
},
|
||||
{
|
||||
key: 'nut_port', configKey: 'port',
|
||||
label: 'providers.nutPort', type: 'number',
|
||||
min: 1, max: 65535, defaultValue: 3493,
|
||||
},
|
||||
{
|
||||
key: 'nut_username', configKey: 'username',
|
||||
label: 'providers.nutUsername', type: 'text',
|
||||
optional: true, hint: 'providers.nutUsernameHint',
|
||||
},
|
||||
{
|
||||
key: 'nut_password', configKey: 'password',
|
||||
label: 'providers.nutPassword', type: 'password',
|
||||
optional: true, hint: 'providers.nutPasswordHint',
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form) {
|
||||
const config: Record<string, any> = {
|
||||
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;
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged() {
|
||||
// NUT fields are always sent (no secret masking)
|
||||
return true;
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_ups_online', label: 'trackingConfig.upsOnline', default: true },
|
||||
{ key: 'track_ups_on_battery', label: 'trackingConfig.upsOnBattery', default: true },
|
||||
{ key: 'track_ups_low_battery', label: 'trackingConfig.upsLowBattery', default: true },
|
||||
{ key: 'track_ups_battery_restored', label: 'trackingConfig.upsBatteryRestored', default: true },
|
||||
{ key: 'track_ups_comms_lost', label: 'trackingConfig.upsCommsLost', default: true },
|
||||
{ key: 'track_ups_comms_restored', label: 'trackingConfig.upsCommsRestored', default: true },
|
||||
{ key: 'track_ups_replace_battery', label: 'trackingConfig.upsReplaceBattery', default: true },
|
||||
{ key: 'track_ups_overload', label: 'trackingConfig.upsOverload', default: true },
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.upsDevices',
|
||||
icon: 'mdiBatteryCharging80',
|
||||
placeholder: 'notificationTracker.selectUpsDevices',
|
||||
countLabel: 'notificationTracker.devices_count',
|
||||
desc: (col) => col.description || '',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const plankaDescriptor: ProviderDescriptor = {
|
||||
type: 'planka',
|
||||
defaultName: 'Planka',
|
||||
icon: 'mdiViewDashboard',
|
||||
hasUrl: true,
|
||||
urlPlaceholder: 'https://planka.example.com',
|
||||
webhookBased: true,
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'webhook_secret', configKey: 'webhook_secret',
|
||||
label: 'providers.webhookSecret', editLabel: 'providers.webhookSecretKeep',
|
||||
type: 'password', required: 'create-only', hint: 'providers.plankaWebhookSecretHint',
|
||||
},
|
||||
{
|
||||
key: 'api_key', configKey: 'api_key',
|
||||
label: 'providers.apiKey',
|
||||
type: 'password', optional: true, hint: 'providers.plankaApiKeyHint',
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, any> = { url: form.url };
|
||||
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) {
|
||||
return { config, error: 'providers.webhookSecretRequired' };
|
||||
}
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
return form.url !== (existing.url || '') ||
|
||||
!!form.api_key || !!form.webhook_secret;
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_card_created', label: 'trackingConfig.cardCreated', default: true },
|
||||
{ key: 'track_card_updated', label: 'trackingConfig.cardUpdated', default: false },
|
||||
{ key: 'track_card_moved', label: 'trackingConfig.cardMoved', default: true },
|
||||
{ key: 'track_card_deleted', label: 'trackingConfig.cardDeleted', default: false },
|
||||
{ key: 'track_card_commented', label: 'trackingConfig.cardCommented', default: true },
|
||||
{ key: 'track_comment_updated', label: 'trackingConfig.commentUpdated', default: false },
|
||||
{ key: 'track_board_created', label: 'trackingConfig.boardCreated', default: true },
|
||||
{ key: 'track_board_updated', label: 'trackingConfig.boardUpdated', default: false },
|
||||
{ key: 'track_board_deleted', label: 'trackingConfig.boardDeleted', default: true },
|
||||
{ key: 'track_list_created', label: 'trackingConfig.listCreated', default: false },
|
||||
{ key: 'track_list_updated', label: 'trackingConfig.listUpdated', default: false },
|
||||
{ key: 'track_list_deleted', label: 'trackingConfig.listDeleted', default: false },
|
||||
{ key: 'track_attachment_created', label: 'trackingConfig.attachmentCreated', default: true },
|
||||
{ key: 'track_card_label_added', label: 'trackingConfig.cardLabelAdded', default: false },
|
||||
{ key: 'track_task_completed', label: 'trackingConfig.taskCompleted', default: true },
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.boards',
|
||||
icon: 'mdiViewDashboard',
|
||||
placeholder: 'notificationTracker.selectBoards',
|
||||
countLabel: 'notificationTracker.boards_count',
|
||||
desc: () => '',
|
||||
},
|
||||
|
||||
webhookUrlPattern: '/api/webhooks/planka/{id}',
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const schedulerDescriptor: ProviderDescriptor = {
|
||||
type: 'scheduler',
|
||||
defaultName: 'Scheduler',
|
||||
icon: 'mdiClockOutline',
|
||||
hasUrl: false,
|
||||
|
||||
configFields: [],
|
||||
|
||||
buildConfig() {
|
||||
return { config: {} };
|
||||
},
|
||||
|
||||
hasConfigChanged() {
|
||||
return false;
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_scheduled_message', label: 'trackingConfig.scheduledMessage', default: true },
|
||||
],
|
||||
|
||||
collectionMeta: null,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Provider descriptor interface.
|
||||
*
|
||||
* Each service provider type has exactly one descriptor that declares
|
||||
* everything the UI needs: config form fields, event tracking fields,
|
||||
* collection metadata, validation, and provider-specific hooks.
|
||||
*
|
||||
* Components consume descriptors via the registry (see ./index.ts)
|
||||
* instead of if/else chains keyed on provider type strings.
|
||||
*/
|
||||
|
||||
import type { api } from '$lib/api';
|
||||
|
||||
// ── Config form ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ConfigField {
|
||||
/** Form field key (flat namespace, e.g. "api_key", "nut_host"). */
|
||||
key: string;
|
||||
/** Key in the config payload sent to the API (defaults to `key`). */
|
||||
configKey?: string;
|
||||
/** i18n key for the field label. */
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number';
|
||||
placeholder?: string;
|
||||
/**
|
||||
* - `true` → always required
|
||||
* - `'create-only'` → required only when creating, blank = "keep existing" on edit
|
||||
* - `false` / omitted → optional
|
||||
*/
|
||||
required?: boolean | 'create-only';
|
||||
/** Show "(optional)" suffix next to label. */
|
||||
optional?: boolean;
|
||||
/** i18n key for hint text below the field. */
|
||||
hint?: string;
|
||||
/** Edit-mode label override (e.g. "API key (leave blank to keep)"). */
|
||||
editLabel?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** Default value for this field. */
|
||||
defaultValue?: string | number;
|
||||
}
|
||||
|
||||
// ── Event tracking (TrackingConfig form) ─────────────────────────────
|
||||
|
||||
export interface EventTrackingField {
|
||||
/** Form field key, e.g. "track_assets_added". */
|
||||
key: string;
|
||||
/** i18n key for checkbox label. */
|
||||
label: string;
|
||||
/** Default value when creating a new tracking config. */
|
||||
default: boolean;
|
||||
/** Optional i18n key for a Hint tooltip. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Extra non-boolean fields shown after event checkboxes (e.g. max_assets). */
|
||||
export interface ExtraTrackingField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'grid-select';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
gridItems?: string;
|
||||
gridColumns?: number;
|
||||
hint?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: string | number;
|
||||
}
|
||||
|
||||
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
||||
export interface FeatureSection {
|
||||
/** Unique key, e.g. "periodic", "scheduled", "memory". */
|
||||
key: string;
|
||||
/** i18n key for fieldset legend. */
|
||||
legend: string;
|
||||
/** Hint tooltip for legend. */
|
||||
legendHint?: string;
|
||||
/** Form field that toggles this section on/off. */
|
||||
enabledField: string;
|
||||
/** Default value for the enabled field. */
|
||||
enabledDefault: boolean;
|
||||
/** Fields shown when section is enabled. */
|
||||
fields: ExtraTrackingField[];
|
||||
/** Boolean fields (checkboxes) inside the section. */
|
||||
checkboxes?: EventTrackingField[];
|
||||
}
|
||||
|
||||
// ── Collection metadata (TrackerForm) ────────────────────────────────
|
||||
|
||||
export interface CollectionMeta {
|
||||
/** i18n key for collection noun ("Albums", "Repositories", etc.). */
|
||||
label: string;
|
||||
/** MDI icon name. */
|
||||
icon: string;
|
||||
/** i18n key for multi-select placeholder. */
|
||||
placeholder: string;
|
||||
/** i18n key for count badge in tracker list (e.g. "3 albums"). */
|
||||
countLabel: string;
|
||||
/** Per-collection description line. */
|
||||
desc: (col: any) => string;
|
||||
}
|
||||
|
||||
// ── Main descriptor ──────────────────────────────────────────────────
|
||||
|
||||
export interface ProviderDescriptor {
|
||||
/** Provider type string (must match backend). */
|
||||
type: string;
|
||||
/** Default display name for new providers. */
|
||||
defaultName: string;
|
||||
/** MDI icon name. */
|
||||
icon: string;
|
||||
|
||||
// ── Provider config form ──
|
||||
/** Whether this provider shows a URL field. */
|
||||
hasUrl: boolean;
|
||||
/** Placeholder for the URL field. */
|
||||
urlPlaceholder?: string;
|
||||
/** Provider-specific config fields (API key, webhook secret, etc.). */
|
||||
configFields: ConfigField[];
|
||||
/** Build the config payload from flat form state. Return error i18n key to abort. */
|
||||
buildConfig(form: Record<string, any>, editing: boolean): { config: Record<string, any>; error?: string };
|
||||
/** Detect whether any config field changed during edit (secrets are blank on load). */
|
||||
hasConfigChanged(form: Record<string, any>, existing: Record<string, any>): boolean;
|
||||
|
||||
// ── Event tracking ──
|
||||
/** Checkbox fields for the "Event tracking" fieldset. */
|
||||
eventFields: EventTrackingField[];
|
||||
/** Extra non-boolean controls shown after the event checkboxes. */
|
||||
extraTrackingFields?: ExtraTrackingField[];
|
||||
/** Feature sections (periodic, scheduled, memory) — only for providers that support them. */
|
||||
featureSections?: FeatureSection[];
|
||||
|
||||
// ── Collections / Trackers ──
|
||||
/** Null means this provider has no collections (e.g. scheduler). */
|
||||
collectionMeta: CollectionMeta | null;
|
||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||
webhookBased?: boolean;
|
||||
|
||||
// ── Webhook URL display ──
|
||||
/** Pattern shown in edit mode, e.g. "/api/webhooks/gitea/{id}". */
|
||||
webhookUrlPattern?: string;
|
||||
|
||||
// ── Provider-specific hooks ──
|
||||
/**
|
||||
* Called after collection selection changes (before save).
|
||||
* E.g. Immich shared-link validation.
|
||||
*/
|
||||
onBeforeSave?(ctx: {
|
||||
form: Record<string, any>;
|
||||
previousCollectionIds: string[];
|
||||
collections: any[];
|
||||
api: typeof api;
|
||||
}): Promise<{ warnings?: { id: string; name: string; issue: string }[]; proceed: boolean }>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
import type { DashboardStatus } from '$lib/types';
|
||||
let status = $state<DashboardStatus | null>(null);
|
||||
@@ -181,8 +182,18 @@
|
||||
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
||||
: displayProviders);
|
||||
|
||||
const statCards = $derived(status ? [
|
||||
{ icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' },
|
||||
const providerCard = $derived.by(() => {
|
||||
const gp = globalProviderFilter.provider;
|
||||
if (gp) {
|
||||
const desc = getDescriptor(gp.type);
|
||||
return { icon: providerDefaultIcon(gp), label: '', literalLabel: desc?.defaultName ?? gp.type, value: 0, literalValue: gp.name, color: '#0d9488' };
|
||||
}
|
||||
return { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' };
|
||||
});
|
||||
|
||||
interface StatCard { icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; color: string }
|
||||
const statCards = $derived<StatCard[]>(status ? [
|
||||
providerCard,
|
||||
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
||||
@@ -238,9 +249,9 @@
|
||||
<MdiIcon name={card.icon} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
|
||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
|
||||
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { providerDefaultIcon } from '$lib/grid-items';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
|
||||
|
||||
import TrackerForm from './TrackerForm.svelte';
|
||||
@@ -153,33 +154,22 @@
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
|
||||
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
|
||||
if (newAlbumIds.length > 0 && form.provider_id && selectedProviderType === 'immich') {
|
||||
// Delegate provider-specific pre-save checks to the descriptor
|
||||
const desc = getDescriptor(selectedProviderType);
|
||||
if (desc?.onBeforeSave && form.provider_id) {
|
||||
linkCheckLoading = true;
|
||||
try {
|
||||
const missingAlbums: { id: string; name: string; issue: string }[] = [];
|
||||
for (const albumId of newAlbumIds) {
|
||||
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
||||
const links = await api<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
||||
if (!validLink) {
|
||||
const album = collections.find(c => c.id === albumId);
|
||||
const problematicLink = links.find((l) => l.is_expired || l.has_password);
|
||||
missingAlbums.push({
|
||||
id: albumId,
|
||||
name: album?.albumName || album?.name || albumId,
|
||||
issue: problematicLink
|
||||
? (problematicLink.is_expired ? 'expired' : 'password-protected')
|
||||
: 'missing',
|
||||
});
|
||||
const result = await desc.onBeforeSave({
|
||||
form, previousCollectionIds, collections, api,
|
||||
});
|
||||
if (!result.proceed) {
|
||||
if (result.warnings?.length) {
|
||||
linkWarning = { albums: result.warnings, providerId: form.provider_id };
|
||||
}
|
||||
}
|
||||
if (missingAlbums.length > 0) {
|
||||
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
|
||||
linkCheckLoading = false;
|
||||
return;
|
||||
}
|
||||
} catch (e) { console.warn('Shared link check failed, proceeding:', e); }
|
||||
} catch (err) { console.warn('Pre-save check failed, proceeding:', err); }
|
||||
linkCheckLoading = false;
|
||||
}
|
||||
|
||||
@@ -277,15 +267,10 @@
|
||||
return p?.name || `#${id}`;
|
||||
}
|
||||
|
||||
const collectionCountLabel: Record<string, string> = {
|
||||
immich: 'notificationTracker.albums_count',
|
||||
gitea: 'notificationTracker.repos_count',
|
||||
planka: 'notificationTracker.boards_count',
|
||||
nut: 'notificationTracker.devices_count',
|
||||
};
|
||||
function getCollectionLabel(tracker: Tracker): string {
|
||||
const pt = getProviderType(tracker);
|
||||
return t(collectionCountLabel[pt] || 'notificationTracker.albums_count');
|
||||
const desc = getDescriptor(pt);
|
||||
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
|
||||
}
|
||||
|
||||
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
interface Props {
|
||||
form: {
|
||||
@@ -45,17 +46,10 @@
|
||||
formatDate,
|
||||
}: Props = $props();
|
||||
|
||||
let descriptor = $derived(getDescriptor(providerType));
|
||||
let isScheduler = $derived(providerType === 'scheduler');
|
||||
let isWebhook = $derived(providerType === 'gitea' || providerType === 'planka');
|
||||
|
||||
// Collection label/icon/desc per provider type
|
||||
const collectionMeta: Record<string, { label: string; icon: string; placeholder: string; desc: (col: any) => string }> = {
|
||||
immich: { label: t('notificationTracker.albums'), icon: 'mdiImageMultiple', placeholder: t('notificationTracker.selectAlbums'), desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets` },
|
||||
gitea: { label: t('notificationTracker.repositories'), icon: 'mdiGit', placeholder: t('notificationTracker.selectRepositories'), desc: () => '' },
|
||||
planka: { label: t('notificationTracker.boards'), icon: 'mdiViewDashboard', placeholder: t('notificationTracker.selectBoards'), desc: () => '' },
|
||||
nut: { label: t('notificationTracker.upsDevices'), icon: 'mdiBatteryCharging80', placeholder: t('notificationTracker.selectUpsDevices'), desc: (col) => col.description || '' },
|
||||
};
|
||||
let colMeta = $derived(collectionMeta[providerType] || { label: t('notificationTracker.albums'), icon: 'mdiServer', placeholder: t('notificationTracker.selectAlbums'), desc: () => '' });
|
||||
let isWebhook = $derived(descriptor?.webhookBased ?? false);
|
||||
let colMeta = $derived(descriptor?.collectionMeta);
|
||||
|
||||
// Custom variable management for scheduler
|
||||
function addVariable() {
|
||||
@@ -99,9 +93,9 @@
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
|
||||
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
|
||||
</div>
|
||||
{#if !isScheduler && collections.length > 0}
|
||||
{#if !isScheduler && colMeta && collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{colMeta.label}</label>
|
||||
<label class="block text-sm font-medium mb-1">{t(colMeta.label)}</label>
|
||||
<MultiEntitySelect
|
||||
items={collections.map(col => ({
|
||||
value: col.id,
|
||||
@@ -110,7 +104,7 @@
|
||||
desc: colMeta.desc(col),
|
||||
}))}
|
||||
bind:values={form.collection_ids}
|
||||
placeholder={colMeta.placeholder}
|
||||
placeholder={t(colMeta.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,26 +4,28 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import { providerTypeItems } from '$lib/grid-items';
|
||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||
|
||||
let providerType = $state('immich');
|
||||
let name = $state('');
|
||||
let icon = $state('');
|
||||
let url = $state('');
|
||||
let apiKey = $state('');
|
||||
let externalDomain = $state('');
|
||||
let form = $state(buildProviderFormDefaults());
|
||||
let error = $state('');
|
||||
let testing = $state(false);
|
||||
let saving = $state(false);
|
||||
let descriptor = $derived(getDescriptor(form.type));
|
||||
|
||||
async function testAndSave() {
|
||||
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
|
||||
const desc = descriptor;
|
||||
if (!desc) { error = 'Select a provider type'; 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: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
|
||||
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' });
|
||||
@@ -44,12 +46,16 @@
|
||||
}
|
||||
|
||||
async function saveWithoutTest() {
|
||||
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
|
||||
const desc = descriptor;
|
||||
if (!desc) { error = 'Select a provider type'; 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: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
|
||||
body: JSON.stringify({ type: form.type, name: form.name || desc.defaultName, icon: form.icon, config }),
|
||||
});
|
||||
snackSuccess(t('snack.providerSaved'));
|
||||
window.location.href = '/providers';
|
||||
@@ -66,30 +72,46 @@
|
||||
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label>
|
||||
<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={icon} onselect={(v: string) => icon = v} />
|
||||
<input id="prv-name" bind:value={name} placeholder="My Immich Server" class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<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 providerType === 'immich'}
|
||||
{#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={url} required placeholder={t('providers.urlPlaceholder')} 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-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')}</label>
|
||||
<input id="prv-key" type="password" bind:value={apiKey} required autocomplete="off" 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-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" type="url" bind:value={externalDomain} 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)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.externalDomainHint')}</p>
|
||||
<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 === '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}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-[var(--color-error-fg)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
@@ -18,8 +18,14 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||
import type { TrackingConfig } from '$lib/types';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||||
};
|
||||
|
||||
let allConfigs = $derived(trackingConfigsCache.items);
|
||||
let filterText = $state('');
|
||||
let filterType = $state('');
|
||||
@@ -34,34 +40,12 @@
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
const defaultForm = () => ({
|
||||
const defaultForm = (): Record<string, any> => ({
|
||||
provider_type: '', name: '', icon: '',
|
||||
// Immich event tracking
|
||||
track_assets_added: true, track_assets_removed: false,
|
||||
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
include_tags: true, include_asset_details: false,
|
||||
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
|
||||
periodic_enabled: false, periodic_interval_days: 1, periodic_start_date: '2025-01-01', periodic_times: '12:00',
|
||||
scheduled_enabled: false, scheduled_times: '09:00', scheduled_collection_mode: 'per_collection',
|
||||
scheduled_limit: 10, scheduled_favorite_only: false, scheduled_asset_type: 'all',
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
// Scheduler event tracking
|
||||
track_scheduled_message: true,
|
||||
// Gitea event tracking
|
||||
track_push: true, track_issue_opened: true, track_issue_closed: true, track_issue_commented: false,
|
||||
track_pr_opened: true, track_pr_closed: true, track_pr_merged: true, track_pr_commented: false,
|
||||
track_release_published: true,
|
||||
// Planka event tracking
|
||||
track_card_created: true, track_card_updated: false, track_card_moved: true, track_card_deleted: false,
|
||||
track_card_commented: true, track_comment_updated: false,
|
||||
track_board_created: true, track_board_updated: false, track_board_deleted: true,
|
||||
track_list_created: false, track_list_updated: false, track_list_deleted: false,
|
||||
track_attachment_created: true, track_card_label_added: false, track_task_completed: true,
|
||||
...buildTrackingFormDefaults(),
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let form: Record<string, any> = $state(defaultForm());
|
||||
let descriptor = $derived(getDescriptor(form.provider_type));
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
@@ -130,137 +114,86 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Event tracking -->
|
||||
<!-- Event tracking — driven by descriptor -->
|
||||
{#if descriptor}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
{#if form.provider_type === 'scheduler'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_scheduled_message} /> {t('trackingConfig.scheduledMessage')}</label>
|
||||
{#each descriptor.eventFields as field (field.key)}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form[field.key]} />
|
||||
{t(field.label)}
|
||||
{#if field.hint}<Hint text={t(field.hint)} />{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if form.provider_type === 'gitea'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_push} /> {t('trackingConfig.push')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_opened} /> {t('trackingConfig.issueOpened')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_closed} /> {t('trackingConfig.issueClosed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_commented} /> {t('trackingConfig.issueCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_opened} /> {t('trackingConfig.prOpened')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_closed} /> {t('trackingConfig.prClosed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_merged} /> {t('trackingConfig.prMerged')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_commented} /> {t('trackingConfig.prCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_release_published} /> {t('trackingConfig.releasePublished')}</label>
|
||||
</div>
|
||||
{:else if form.provider_type === 'planka'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_created} /> {t('trackingConfig.cardCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_updated} /> {t('trackingConfig.cardUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_moved} /> {t('trackingConfig.cardMoved')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_deleted} /> {t('trackingConfig.cardDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_commented} /> {t('trackingConfig.cardCommented')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_comment_updated} /> {t('trackingConfig.commentUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_board_created} /> {t('trackingConfig.boardCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_board_updated} /> {t('trackingConfig.boardUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_board_deleted} /> {t('trackingConfig.boardDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_list_created} /> {t('trackingConfig.listCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_list_updated} /> {t('trackingConfig.listUpdated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_list_deleted} /> {t('trackingConfig.listDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_attachment_created} /> {t('trackingConfig.attachmentCreated')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_card_label_added} /> {t('trackingConfig.cardLabelAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_task_completed} /> {t('trackingConfig.taskCompleted')}</label>
|
||||
</div>
|
||||
{:else if form.provider_type === 'nut'}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_online} /> {t('trackingConfig.upsOnline')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_on_battery} /> {t('trackingConfig.upsOnBattery')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_low_battery} /> {t('trackingConfig.upsLowBattery')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_battery_restored} /> {t('trackingConfig.upsBatteryRestored')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_comms_lost} /> {t('trackingConfig.upsCommsLost')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_comms_restored} /> {t('trackingConfig.upsCommsRestored')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_replace_battery} /> {t('trackingConfig.upsReplaceBattery')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_ups_overload} /> {t('trackingConfig.upsOverload')}</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_collection_renamed} /> {t('trackingConfig.albumRenamed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_collection_deleted} /> {t('trackingConfig.albumDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_sharing_changed} /> {t('trackingConfig.sharingChanged')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_images} /> {t('trackingConfig.trackImages')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_videos} /> {t('trackingConfig.trackVideos')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.notify_favorites_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_tags} /> {t('trackingConfig.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if form.provider_type === 'immich'}
|
||||
{#if descriptor.extraTrackingFields?.length}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
||||
<input id="tc-max" type="number" bind:value={form.max_assets_to_show} 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 class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
||||
<IconGridSelect items={sortByItems()} bind:value={form.assets_order_by} columns={2} compact />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
||||
<IconGridSelect items={sortOrderItems()} bind:value={form.assets_order} columns={2} compact />
|
||||
</div>
|
||||
{#each descriptor.extraTrackingFields as field (field.key)}
|
||||
<div>
|
||||
<label class="block text-xs mb-1">
|
||||
{t(field.label)}
|
||||
{#if field.hint}<Hint text={t(field.hint)} />{/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}
|
||||
<input type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if form.provider_type === 'immich'}
|
||||
<!-- Periodic summary -->
|
||||
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
|
||||
{#each descriptor.featureSections ?? [] as section (section.key)}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.periodic_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.periodic_enabled}
|
||||
<legend class="text-sm font-medium px-1">
|
||||
{t(section.legend)}
|
||||
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
|
||||
</legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1">
|
||||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||||
{t('trackingConfig.enabled')}
|
||||
</label>
|
||||
{#if form[section.enabledField]}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.intervalDays')}</label><input type="number" bind:value={form.periodic_interval_days} min="1" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.startDate')}<Hint text={t('hints.periodicStartDate')} /></label><input type="date" bind:value={form.periodic_start_date} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.periodic_times} placeholder="12:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
{#each section.fields as field (field.key)}
|
||||
<div>
|
||||
<label class="block text-xs mb-1">
|
||||
{t(field.label)}
|
||||
{#if field.hint}<Hint text={t(field.hint)} />{/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}
|
||||
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'}
|
||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#each section.checkboxes ?? [] as cb (cb.key)}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form[cb.key]} />
|
||||
{t(cb.label)}
|
||||
{#if cb.hint}<Hint text={t(cb.hint)} />{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Scheduled assets -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.scheduledAssets')}<Hint text={t('hints.scheduledAssets')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.scheduled_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.scheduled_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<IconGridSelect items={albumModeItems()} bind:value={form.scheduled_collection_mode} columns={3} compact /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<IconGridSelect items={assetTypeItems()} bind:value={form.scheduled_asset_type} columns={3} compact /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
{/each}
|
||||
{:else if form.provider_type}
|
||||
<Card>
|
||||
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={18} />
|
||||
{t('trackingConfig.unknownProviderType')}: {form.provider_type}
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Memory mode -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.memoryMode')}<Hint text={t('hints.memoryMode')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.memory_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.memory_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.memorySource')}<Hint text={t('hints.memorySource')} /></label>
|
||||
<IconGridSelect items={memorySourceItems()} bind:value={form.memory_source} columns={2} compact /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<IconGridSelect items={albumModeItems()} bind:value={form.memory_collection_mode} columns={3} compact /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<IconGridSelect items={assetTypeItems()} bind:value={form.memory_asset_type} columns={3} compact /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
@@ -294,6 +227,7 @@
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as config}
|
||||
{@const desc = getDescriptor(config.provider_type)}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -303,7 +237,7 @@
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{[config.track_assets_added && t('trackingConfig.added'), config.track_assets_removed && t('trackingConfig.removed'), config.track_collection_renamed && t('trackingConfig.renamed'), config.track_collection_deleted && t('trackingConfig.deleted')].filter(Boolean).join(', ')}
|
||||
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
|
||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||
|
||||
@@ -15,7 +15,8 @@ fi
|
||||
# Start backend
|
||||
NOTIFY_BRIDGE_DATA_DIR=./test-data \
|
||||
NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars \
|
||||
nohup python -m uvicorn notify_bridge_server.main:app \
|
||||
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || command -v py 2>/dev/null)
|
||||
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
|
||||
--host 0.0.0.0 --port 8420 > /dev/null 2>&1 &
|
||||
|
||||
sleep 3
|
||||
|
||||
Reference in New Issue
Block a user