Files
notify-bridge/frontend/src/lib/grid-items.ts
T
alexei.dolgolyov a7a2b4efa4 feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
2026-04-22 01:13:11 +03:00

149 lines
8.0 KiB
TypeScript

/**
* 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';
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) {
const desc = getDescriptor(provider.type);
if (desc) return desc.icon;
}
return 'mdiServer';
}
// --- Sort ---
export const sortByItems = (): GridItem[] => [
{ value: 'none', icon: 'mdiMinus', label: t('trackingConfig.sortNone'), desc: t('gridDesc.sortNone') },
{ value: 'date', icon: 'mdiCalendar', label: t('trackingConfig.sortDate'), desc: t('gridDesc.sortDate') },
{ value: 'rating', icon: 'mdiStar', label: t('trackingConfig.sortRating'), desc: t('gridDesc.sortRating') },
{ value: 'name', icon: 'mdiSortAlphabeticalAscending', label: t('trackingConfig.sortName'), desc: t('gridDesc.sortName') },
{ value: 'random', icon: 'mdiDice3', label: t('trackingConfig.sortRandom'), desc: t('gridDesc.sortRandom') },
];
export const sortOrderItems = (): GridItem[] => [
{ value: 'descending', icon: 'mdiSortDescending', label: t('trackingConfig.orderDesc'), desc: t('gridDesc.orderDesc') },
{ value: 'ascending', icon: 'mdiSortAscending', label: t('trackingConfig.orderAsc'), desc: t('gridDesc.orderAsc') },
];
// --- Album mode ---
export const albumModeItems = (): GridItem[] => [
{ value: 'per_collection', icon: 'mdiViewGrid', label: t('trackingConfig.albumModePerAlbum'), desc: t('gridDesc.albumModePerAlbum') },
{ value: 'combined', icon: 'mdiSetMerge', label: t('trackingConfig.albumModeCombined'), desc: t('gridDesc.albumModeCombined') },
{ value: 'random', icon: 'mdiDice3', label: t('trackingConfig.albumModeRandom'), desc: t('gridDesc.albumModeRandom') },
];
// --- Asset type ---
export const assetTypeItems = (): GridItem[] => [
{ value: 'all', icon: 'mdiSelectAll', label: t('trackingConfig.assetTypeAll'), desc: t('gridDesc.assetTypeAll') },
{ value: 'photo', icon: 'mdiImage', label: t('trackingConfig.assetTypePhoto'), desc: t('gridDesc.assetTypePhoto') },
{ value: 'video', icon: 'mdiVideo', label: t('trackingConfig.assetTypeVideo'), desc: t('gridDesc.assetTypeVideo') },
];
// --- Memory source ---
export const memorySourceItems = (): GridItem[] => [
{ value: 'albums', icon: 'mdiImageMultiple', label: t('trackingConfig.memorySourceAlbums'), desc: t('gridDesc.memorySourceAlbums') },
{ value: 'native', icon: 'mdiMemory', label: t('trackingConfig.memorySourceNative'), desc: t('gridDesc.memorySourceNative') },
];
// --- Webhook auth mode ---
export const webhookAuthModeItems = (): GridItem[] => [
{ value: 'none', icon: 'mdiLockOpen', label: t('providers.authNone'), desc: t('gridDesc.authNone') },
{ value: 'bearer_token', icon: 'mdiKey', label: t('providers.authBearer'), desc: t('gridDesc.authBearer') },
{ value: 'hmac_sha256', icon: 'mdiShieldKey', label: t('providers.authHmac'), desc: t('gridDesc.authHmac') },
];
// --- Locale ---
export const localeItems = (): GridItem[] => [
{ value: 'en', icon: 'mdiAlphabeticalVariant', label: 'English', desc: t('gridDesc.localeEn') },
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
];
// --- Response mode ---
export const responseModeItems = (tFn: typeof t): GridItem[] => [
{ value: 'media', icon: 'mdiImage', label: tFn('commandConfig.modeMedia'), desc: tFn('gridDesc.modeMedia') },
{ value: 'text', icon: 'mdiText', label: tFn('commandConfig.modeText'), desc: tFn('gridDesc.modeText') },
];
// --- Event type filter (dashboard) ---
export const eventTypeFilterItems = (): GridItem[] => [
{ value: '', icon: 'mdiFilterOff', label: t('dashboard.allEvents'), desc: t('gridDesc.allEvents') },
{ value: 'assets_added', icon: 'mdiImagePlus', label: t('dashboard.filterAssetsAdded'), desc: t('gridDesc.assetsAdded') },
{ value: 'assets_removed', icon: 'mdiImageMinus', label: t('dashboard.filterAssetsRemoved'), desc: t('gridDesc.assetsRemoved') },
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
];
// --- Sort filter (dashboard) ---
export const sortFilterItems = (): GridItem[] => [
{ value: 'newest', icon: 'mdiSortClockDescending', label: t('dashboard.newestFirst'), desc: t('gridDesc.newestFirst') },
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [
{ value: '', icon: 'mdiMinus', label: t('targets.chatActionNone'), desc: t('gridDesc.chatActionNone') },
{ value: 'typing', icon: 'mdiKeyboard', label: t('targets.chatActionTyping'), desc: t('gridDesc.chatActionTyping') },
{ value: 'upload_photo', icon: 'mdiImagePlus', label: t('targets.chatActionUploadPhoto'), desc: t('gridDesc.chatActionUploadPhoto') },
{ value: 'upload_video', icon: 'mdiVideoPlus', label: t('targets.chatActionUploadVideo'), desc: t('gridDesc.chatActionUploadVideo') },
{ value: 'upload_document', icon: 'mdiFileUpload', label: t('targets.chatActionUploadDoc'), desc: t('gridDesc.chatActionUploadDoc') },
{ value: 'record_video', icon: 'mdiVideo', label: t('targets.chatActionRecordVideo'), desc: t('gridDesc.chatActionRecordVideo') },
{ value: 'record_voice', icon: 'mdiMicrophone', label: t('targets.chatActionRecordVoice'), desc: t('gridDesc.chatActionRecordVoice') },
];
// --- Preview target type ---
export const previewTargetTypeItems = (): GridItem[] => [
{ value: 'telegram', icon: 'mdiSend', label: 'Telegram', desc: t('gridDesc.previewTelegram') },
{ value: 'webhook', icon: 'mdiWebhook', label: 'Webhook', desc: t('gridDesc.previewWebhook') },
{ value: 'email', icon: 'mdiEmailOutline', label: 'Email', desc: t('gridDesc.previewEmail') },
{ value: 'discord', icon: 'mdiChat', label: 'Discord', desc: t('gridDesc.previewDiscord') },
{ value: 'slack', icon: 'mdiSlack', label: 'Slack', desc: t('gridDesc.previewSlack') },
{ value: 'ntfy', icon: 'mdiBellOutline', label: 'ntfy', desc: t('gridDesc.previewNtfy') },
{ value: 'matrix', icon: 'mdiMatrix', label: 'Matrix', desc: t('gridDesc.previewMatrix') },
];
// --- 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') },
...allDescriptors().map(descriptorToGridItem),
];
/** Provider type selector (no "All" option). */
export const providerTypeItems = (): GridItem[] =>
allDescriptors().map(descriptorToGridItem);