diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 8625244..cd8bfa1 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -268,6 +268,7 @@ "preview": "Preview", "variables": "Variables", "assetFields": "Asset fields (in {% for asset in added_assets %})", + "albumFields": "Album fields (in {% for album in albums %})", "confirmDelete": "Delete this template config?" }, "templateVars": { @@ -275,38 +276,50 @@ "message_assets_removed": { "description": "Notification when assets are removed from an album" }, "message_album_renamed": { "description": "Notification when an album is renamed" }, "message_album_deleted": { "description": "Notification when an album is deleted" }, - "periodic_summary_message": { "description": "Periodic album summary with stats" }, - "scheduled_assets_message": { "description": "Scheduled asset picks from albums" }, - "memory_mode_message": { "description": "\"On This Day\" memories from past years" }, + "periodic_summary_message": { "description": "Periodic album summary (scheduler not yet implemented)" }, + "scheduled_assets_message": { "description": "Scheduled asset delivery (scheduler not yet implemented)" }, + "memory_mode_message": { "description": "\"On This Day\" memories (scheduler not yet implemented)" }, + "album_id": "Album ID (UUID)", "album_name": "Album name", - "album_url": "Public share URL (if available)", + "album_url": "Public share URL (empty if not shared)", "added_count": "Number of assets added", "removed_count": "Number of assets removed", - "change_type": "Type of change", - "people": "Detected people names (use {{ people | join(', ') }})", - "added_assets": "List of added asset objects (use {% for asset in added_assets %})", - "removed_assets": "List of removed asset IDs", - "shared": "Whether album is shared (true/false)", - "video_warning": "Video size warning text", - "old_name": "Previous album name", - "new_name": "New album name", - "albums": "List of album objects (use {% for album in albums %})", - "assets": "List of asset objects (use {% for asset in assets %})", - "date": "Current date/time", + "change_type": "Type of change (assets_added, assets_removed, album_renamed, album_deleted)", + "people": "Detected people names (list, use {{ people | join(', ') }})", + "added_assets": "List of asset dicts (use {% for asset in added_assets %})", + "removed_assets": "List of removed asset IDs (strings)", + "shared": "Whether album is shared (boolean)", + "video_warning": "Video size warning (from template config, only if videos present)", + "old_name": "Previous album name (rename events)", + "new_name": "New album name (rename events)", + "old_shared": "Was album shared before rename (boolean)", + "new_shared": "Is album shared after rename (boolean)", + "albums": "List of album dicts (use {% for album in albums %})", + "assets": "List of asset dicts (use {% for asset in assets %})", + "date": "Current date string", + "asset_id": "Asset ID (UUID)", "asset_filename": "Original filename", "asset_type": "IMAGE or VIDEO", "asset_created_at": "Creation date/time (ISO 8601)", "asset_owner": "Owner display name", + "asset_owner_id": "Owner user ID", "asset_description": "User or EXIF description", - "asset_url": "Public viewer URL", - "asset_download_url": "Direct download URL", - "asset_photo_url": "Preview image URL (images only)", - "asset_playback_url": "Video playback URL (videos only)", - "asset_is_favorite": "Whether asset is favorited", + "asset_people": "People detected in this asset (list)", + "asset_is_favorite": "Whether asset is favorited (boolean)", "asset_rating": "Star rating (1-5 or null)", + "asset_latitude": "GPS latitude (float or null)", + "asset_longitude": "GPS longitude (float or null)", "asset_city": "City name", "asset_state": "State/region name", - "asset_country": "Country name" + "asset_country": "Country name", + "asset_url": "Public viewer URL (if shared)", + "asset_download_url": "Direct download URL (if shared)", + "asset_photo_url": "Preview image URL (images only, if shared)", + "asset_playback_url": "Video playback URL (videos only, if shared)", + "album_name_field": "Album name (in album list)", + "album_asset_count": "Total assets in album", + "album_url_field": "Album share URL", + "album_shared": "Whether album is shared" }, "hints": { "periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index cbf6425..f116815 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -268,6 +268,7 @@ "preview": "Предпросмотр", "variables": "Переменные", "assetFields": "Поля файла (в {% for asset in added_assets %})", + "albumFields": "Поля альбома (в {% for album in albums %})", "confirmDelete": "Удалить эту конфигурацию шаблона?" }, "templateVars": { @@ -275,38 +276,50 @@ "message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" }, "message_album_renamed": { "description": "Уведомление о переименовании альбома" }, "message_album_deleted": { "description": "Уведомление об удалении альбома" }, - "periodic_summary_message": { "description": "Периодическая сводка альбомов со статистикой" }, - "scheduled_assets_message": { "description": "Запланированная подборка фото из альбомов" }, - "memory_mode_message": { "description": "«В этот день» — фото из прошлых лет" }, + "periodic_summary_message": { "description": "Периодическая сводка альбомов (планировщик не реализован)" }, + "scheduled_assets_message": { "description": "Запланированная подборка фото (планировщик не реализован)" }, + "memory_mode_message": { "description": "«В этот день» — воспоминания (планировщик не реализован)" }, + "album_id": "ID альбома (UUID)", "album_name": "Название альбома", - "album_url": "Публичная ссылка (если есть)", + "album_url": "Публичная ссылка (пусто, если не расшарен)", "added_count": "Количество добавленных файлов", "removed_count": "Количество удалённых файлов", - "change_type": "Тип изменения", - "people": "Обнаруженные люди ({{ people | join(', ') }})", - "added_assets": "Список добавленных файлов ({% for asset in added_assets %})", - "removed_assets": "Список ID удалённых файлов", - "shared": "Общий альбом (true/false)", - "video_warning": "Предупреждение о размере видео", - "old_name": "Прежнее название альбома", - "new_name": "Новое название альбома", + "change_type": "Тип изменения (assets_added, assets_removed, album_renamed, album_deleted)", + "people": "Обнаруженные люди (список, {{ people | join(', ') }})", + "added_assets": "Список файлов ({% for asset in added_assets %})", + "removed_assets": "Список ID удалённых файлов (строки)", + "shared": "Общий альбом (boolean)", + "video_warning": "Предупреждение о видео (из конфига шаблона, если есть видео)", + "old_name": "Прежнее название альбома (при переименовании)", + "new_name": "Новое название альбома (при переименовании)", + "old_shared": "Был ли общим до переименования (boolean)", + "new_shared": "Является ли общим после переименования (boolean)", "albums": "Список альбомов ({% for album in albums %})", "assets": "Список файлов ({% for asset in assets %})", - "date": "Текущая дата/время", + "date": "Текущая дата", + "asset_id": "ID файла (UUID)", "asset_filename": "Имя файла", "asset_type": "IMAGE или VIDEO", "asset_created_at": "Дата создания (ISO 8601)", "asset_owner": "Имя владельца", + "asset_owner_id": "ID владельца", "asset_description": "Описание (EXIF или пользовательское)", - "asset_url": "Ссылка для просмотра", - "asset_download_url": "Ссылка для скачивания", - "asset_photo_url": "URL превью (только фото)", - "asset_playback_url": "URL видео (только видео)", - "asset_is_favorite": "В избранном", + "asset_people": "Люди на этом файле (список)", + "asset_is_favorite": "В избранном (boolean)", "asset_rating": "Рейтинг (1-5 или null)", + "asset_latitude": "GPS широта (float или null)", + "asset_longitude": "GPS долгота (float или null)", "asset_city": "Город", "asset_state": "Регион", - "asset_country": "Страна" + "asset_country": "Страна", + "asset_url": "Ссылка для просмотра (если расшарен)", + "asset_download_url": "Ссылка для скачивания (если расшарен)", + "asset_photo_url": "URL превью (только фото, если расшарен)", + "asset_playback_url": "URL видео (только видео, если расшарен)", + "album_name_field": "Название альбома (в списке альбомов)", + "album_asset_count": "Всего файлов в альбоме", + "album_url_field": "Ссылка на альбом", + "album_shared": "Общий альбом" }, "hints": { "periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.", diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 4be01dc..a527b65 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -274,10 +274,10 @@ {/each} - {#if varsRef[showVarsFor].asset_fields} + {#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}

{t('templateConfig.assetFields')}:

- {#each Object.entries(varsRef[showVarsFor].asset_fields || {}) as [name, desc]} + {#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
{'{{ asset.' + name + ' }}'} {t(`templateVars.asset_${name}`, desc as string)} @@ -285,5 +285,16 @@ {/each}
{/if} + {#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'} +
+

{t('templateConfig.albumFields')}:

+ {#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]} +
+ {'{{ album.' + name + ' }}'} + {t(`templateVars.album_${name}`, desc as string)} +
+ {/each} +
+ {/if} {/if} diff --git a/packages/server/src/immich_watcher_server/api/template_configs.py b/packages/server/src/immich_watcher_server/api/template_configs.py index 84b5008..9a4d479 100644 --- a/packages/server/src/immich_watcher_server/api/template_configs.py +++ b/packages/server/src/immich_watcher_server/api/template_configs.py @@ -14,22 +14,37 @@ from ..database.models import TemplateConfig, User router = APIRouter(prefix="/api/template-configs", tags=["template-configs"]) -# Sample asset for template preview +# Sample asset matching what build_asset_detail() actually returns _SAMPLE_ASSET = { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "filename": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T10:30:00", "owner": "Alice", + "owner_id": "user-uuid-1", "description": "Family picnic", - "url": "https://immich.example.com/photos/abc123", - "download_url": "https://immich.example.com/api/assets/abc123/original", - "photo_url": "https://immich.example.com/api/assets/abc123/thumbnail", - "playback_url": "", + "people": ["Alice", "Bob"], "is_favorite": True, "rating": 5, + "latitude": 48.8566, + "longitude": 2.3522, "city": "Paris", "state": "Île-de-France", "country": "France", + "url": "https://immich.example.com/photos/abc123", + "download_url": "https://immich.example.com/api/assets/abc123/original", + "photo_url": "https://immich.example.com/api/assets/abc123/thumbnail", +} + +_SAMPLE_VIDEO_ASSET = { + **_SAMPLE_ASSET, + "id": "d4e5f6a7-b8c9-0123-defg-456789abcdef", + "filename": "VID_002.mp4", + "type": "VIDEO", + "is_favorite": False, + "rating": None, + "photo_url": None, + "playback_url": "https://immich.example.com/api/assets/def456/video", } _SAMPLE_ALBUM = { @@ -39,23 +54,26 @@ _SAMPLE_ALBUM = { "shared": True, } -# Full context covering all possible template variables +# Full context covering ALL possible template variables from _build_event_data() _SAMPLE_CONTEXT = { - # Event variables + # Core event fields (always present) + "album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8", "album_name": "Family Photos", "album_url": "https://immich.example.com/share/abc123", + "change_type": "assets_added", "added_count": 3, "removed_count": 1, - "change_type": "assets_added", - "people": ["Alice", "Bob"], - "added_assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "VID_002.mp4", "type": "VIDEO", "is_favorite": False, "rating": None, "playback_url": "https://immich.example.com/api/assets/def456/video"}], + "added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET], "removed_assets": ["asset-id-1", "asset-id-2"], + "people": ["Alice", "Bob"], "shared": True, "video_warning": "\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit.", - # Rename variables + # Rename fields (always present, empty for non-rename events) "old_name": "Old Album", "new_name": "New Album", - # Scheduled/periodic variables + "old_shared": False, + "new_shared": True, + # Scheduled/periodic variables (for those templates) "albums": [_SAMPLE_ALBUM, {**_SAMPLE_ALBUM, "name": "Vacation 2025", "asset_count": 120}], "assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}], "date": "2026-03-19", diff --git a/packages/server/src/immich_watcher_server/api/template_vars.py b/packages/server/src/immich_watcher_server/api/template_vars.py index 5d59580..bcce7fa 100644 --- a/packages/server/src/immich_watcher_server/api/template_vars.py +++ b/packages/server/src/immich_watcher_server/api/template_vars.py @@ -1,87 +1,127 @@ -"""Template variable reference for all template slots.""" +"""Template variable reference for all template slots. + +This must match what watcher._build_event_data() and +core.asset_utils.build_asset_detail() actually produce. +""" + +_ASSET_FIELDS = { + "id": "Asset ID (UUID)", + "filename": "Original filename", + "type": "IMAGE or VIDEO", + "created_at": "Creation date/time (ISO 8601)", + "owner": "Owner display name", + "owner_id": "Owner user ID", + "description": "User description or EXIF description", + "people": "People detected in this asset (list)", + "is_favorite": "Whether asset is favorited (boolean)", + "rating": "Star rating (1-5 or null)", + "latitude": "GPS latitude (float or null)", + "longitude": "GPS longitude (float or null)", + "city": "City name", + "state": "State/region name", + "country": "Country name", + "url": "Public viewer URL (if shared)", + "download_url": "Direct download URL (if shared)", + "photo_url": "Preview image URL (images only, if shared)", + "playback_url": "Video playback URL (videos only, if shared)", +} + +_ALBUM_FIELDS = { + "name": "Album name", + "asset_count": "Total number of assets", + "url": "Public share URL", + "shared": "Whether album is shared", +} TEMPLATE_VARIABLES: dict[str, dict] = { "message_assets_added": { "description": "Notification when new assets are added to an album", "variables": { + "album_id": "Album ID (UUID)", "album_name": "Album name", - "album_url": "Public share URL (if available)", + "album_url": "Public share URL (empty if not shared)", + "change_type": "Always 'assets_added'", "added_count": "Number of assets added", - "removed_count": "Number of assets removed", - "change_type": "Type of change (assets_added)", - "people": "List of detected people names (use {{ people | join(', ') }})", - "added_assets": "List of asset dicts (use {% for asset in added_assets %})", - "shared": "Whether album is shared (true/false)", - "video_warning": "Video size warning text (if videos present)", - }, - "asset_fields": { - "filename": "Original filename", - "type": "IMAGE or VIDEO", - "created_at": "Creation date/time (ISO 8601)", - "owner": "Owner display name", - "description": "User description or EXIF description", - "url": "Public viewer URL", - "download_url": "Direct download URL", - "photo_url": "Preview image URL (images only)", - "playback_url": "Video playback URL (videos only)", - "is_favorite": "Whether asset is favorited (boolean)", - "rating": "Star rating (1-5 or null)", - "city": "City name", - "state": "State/region name", - "country": "Country name", - "people": "People detected in this asset (list)", + "removed_count": "Always 0", + "added_assets": "List of asset dicts ({% for asset in added_assets %})", + "removed_assets": "Always empty list", + "people": "Detected people across all added assets (list of strings)", + "shared": "Whether album is shared (boolean)", + "video_warning": "Video size warning text (set from template config if videos present)", + "old_name": "Always empty (for rename events)", + "new_name": "Always empty (for rename events)", }, + "asset_fields": _ASSET_FIELDS, }, "message_assets_removed": { - "description": "Notification when assets are removed", + "description": "Notification when assets are removed from an album", "variables": { + "album_id": "Album ID (UUID)", "album_name": "Album name", - "album_url": "Public share URL", + "album_url": "Public share URL (empty if not shared)", + "change_type": "Always 'assets_removed'", + "added_count": "Always 0", "removed_count": "Number of assets removed", - "removed_assets": "List of removed asset IDs", - "change_type": "Type of change (assets_removed)", + "added_assets": "Always empty list", + "removed_assets": "List of removed asset IDs (strings)", + "people": "People in the album (list of strings)", + "shared": "Whether album is shared (boolean)", + "video_warning": "Always empty", + "old_name": "Always empty", + "new_name": "Always empty", }, }, "message_album_renamed": { - "description": "Notification when album is renamed", + "description": "Notification when an album is renamed", "variables": { + "album_id": "Album ID (UUID)", + "album_name": "Current album name (same as new_name)", + "album_url": "Public share URL (empty if not shared)", + "change_type": "Always 'album_renamed'", "old_name": "Previous album name", "new_name": "New album name", - "album_url": "Public share URL", + "old_shared": "Was album shared before (boolean)", + "new_shared": "Is album shared now (boolean)", + "shared": "Whether album is currently shared", + "people": "People in the album (list)", + "added_count": "Always 0", + "removed_count": "Always 0", }, }, "message_album_deleted": { - "description": "Notification when album is deleted", + "description": "Notification when an album is deleted", "variables": { - "album_name": "Album name", + "album_id": "Album ID (UUID)", + "album_name": "Album name (before deletion)", + "change_type": "Always 'album_deleted'", + "shared": "Whether album was shared", }, }, "periodic_summary_message": { - "description": "Periodic album summary", + "description": "Periodic album summary (not yet implemented in scheduler)", "variables": { - "albums": "List of album dicts (use {% for album in albums %})", - }, - "album_fields": { - "name": "Album name", - "asset_count": "Number of assets", - "url": "Public share URL", + "albums": "List of album dicts ({% for album in albums %})", + "date": "Current date string", }, + "album_fields": _ALBUM_FIELDS, }, "scheduled_assets_message": { - "description": "Scheduled asset delivery", + "description": "Scheduled asset delivery (not yet implemented in scheduler)", "variables": { "album_name": "Album name (empty in combined mode)", "album_url": "Public share URL", - "assets": "List of asset dicts (use {% for asset in assets %})", + "assets": "List of asset dicts ({% for asset in assets %})", + "date": "Current date string", }, - "asset_fields": "(same as message_assets_added.asset_fields)", + "asset_fields": _ASSET_FIELDS, }, "memory_mode_message": { - "description": "On This Day memory notification", + "description": "On This Day memory notification (not yet implemented in scheduler)", "variables": { "album_name": "Album name (empty in combined mode)", - "assets": "List of asset dicts (use {% for asset in assets %})", + "assets": "List of asset dicts ({% for asset in assets %})", + "date": "Current date string", }, - "asset_fields": "(same as message_assets_added.asset_fields)", + "asset_fields": _ASSET_FIELDS, }, } diff --git a/plans/phase-11-snackbar-notifications.md b/plans/phase-11-snackbar-notifications.md new file mode 100644 index 0000000..0ce6282 --- /dev/null +++ b/plans/phase-11-snackbar-notifications.md @@ -0,0 +1,96 @@ +# Phase 11: Snackbar Notifications + +**Status**: Pending +**Parent**: [primary-plan.md](primary-plan.md) + +--- + +## Goal + +Replace browser `alert()` calls, silent failures, and inconsistent error handling with a unified snackbar/toast notification system. Every user-triggered action (save, delete, test, etc.) should provide clear visual feedback via a dismissable snackbar that appears at the bottom of the screen. + +--- + +## Design + +### Snackbar Types + +| Type | Color | Icon | Auto-dismiss | Use Case | +|---|---|---|---|---| +| `success` | Green | `mdiCheckCircle` | 3s | Save, delete, test passed | +| `error` | Red | `mdiAlertCircle` | 5s (or manual) | API errors, validation failures | +| `info` | Blue | `mdiInformation` | 3s | Status updates, copy-to-clipboard | +| `warning` | Amber | `mdiAlert` | 4s | Non-critical issues, deprecation notices | + +### Behavior + +- Appears at bottom-center of viewport, above the mobile nav bar +- Stacks vertically (newest on top, max 3 visible) +- Slide-up entrance, fade-out exit animation +- Manual dismiss via X button on all types +- Errors with detail: expandable to show full error message +- `position: fixed` with inline styles (per project convention) + +--- + +## Implementation Plan + +### Task 1: Create Snackbar Store + Component + +**Files to create:** +- `frontend/src/lib/stores/snackbar.svelte.ts` — reactive store using `$state` + - `addSnack(type, message, options?)` — push notification + - `removeSnack(id)` — dismiss + - `snacks` — reactive array of active notifications + - Auto-dismiss timer per snack + +- `frontend/src/lib/components/Snackbar.svelte` — renders snack stack + - Fixed position at bottom-center + - Svelte transitions (fly + fade) + - Icon per type, dismiss button, optional detail expand + - Responsive: full-width on mobile, max-width on desktop + +### Task 2: Mount Snackbar in Layout + +**Files to modify:** +- `frontend/src/routes/+layout.svelte` — add `` component (renders globally) + +### Task 3: Replace All Alert/Silent Patterns + +**Files to modify (each page):** + +| Page | Current Pattern | Replace With | +|---|---|---| +| `servers/+page.svelte` | `alert()` on error, silent success | `snack.success('Server saved')`, `snack.error(err.message)` | +| `trackers/+page.svelte` | `alert()` on error, silent success | `snack.success('Tracker created')`, etc. | +| `targets/+page.svelte` | `alert()` on error, silent success | `snack.success('Target saved')`, `snack.error(...)` | +| `template-configs/+page.svelte` | `alert()` on error, silent success | `snack.success('Template saved')`, etc. | +| `tracking-configs/+page.svelte` | `alert()` on error, silent success | `snack.success('Config saved')`, etc. | +| `telegram-bots/+page.svelte` | `alert()` on error, silent success | `snack.success('Bot registered')`, etc. | +| `users/+page.svelte` | `alert()` on error, silent success | `snack.success('User updated')`, etc. | +| `login/+page.svelte` | `alert()` on error | `snack.error('Invalid credentials')` | + +### Task 4: Add i18n Keys + +**Files to modify:** +- `frontend/src/lib/i18n/en.json` — add `snack.*` keys for all messages +- `frontend/src/lib/i18n/ru.json` — Russian translations + +### Task 5: Add Snackbar to API Helper + +**Files to create/modify:** +- Consider a shared `api.ts` helper that wraps `fetch()` and auto-shows error snackbars on non-2xx responses, reducing boilerplate in each page. + +--- + +## Acceptance Criteria + +- [ ] Every create/update/delete action shows a success snackbar +- [ ] Every API error shows an error snackbar with the server's message +- [ ] No remaining `alert()` calls in the codebase +- [ ] Snackbars auto-dismiss (success: 3s, error: 5s) +- [ ] Snackbars are accessible (role="alert", aria-live) +- [ ] Stacking works correctly (max 3, newest on top) +- [ ] Animations are smooth (slide-up in, fade out) +- [ ] Mobile: snackbar appears above bottom nav bar +- [ ] All snackbar messages are i18n'd (EN + RU) diff --git a/plans/primary-plan.md b/plans/primary-plan.md index d12220b..1f9f895 100644 --- a/plans/primary-plan.md +++ b/plans/primary-plan.md @@ -222,6 +222,12 @@ async def _execute_telegram_notification(self, ...): - Natural language tracker configuration via Telegram chat - **Subplan**: `plans/phase-6-claude-ai-bot.md` +### Phase 11: Snackbar Notifications `[ ]` +- Unified toast/snackbar system for action feedback (success, error, info, warning) +- Replace all `alert()` calls with typed snackbars +- Auto-dismiss, stacking, accessible, animated +- **Subplan**: `plans/phase-11-snackbar-notifications.md` + --- ## Verification