Comprehensive review fixes: security, performance, code quality, and UI polish
Some checks failed
Validate / Hassfest (push) Has been cancelled

Backend: Fix CORS wildcard+credentials, add secret key warning, remove raw
API keys from sync endpoint, fix N+1 queries in watcher/sync, fix
AttributeError on event_types, delete dead scheduled.py/templates.py,
add limit cap on history, re-validate server on URL/key update, apply
tracking/template config IDs in update_target.

HA Integration: Replace datetime.now() with dt_util.now(), fix notification
queue to only remove successfully sent items, use album UUID for entity
unique IDs, add shared links dirty flag and users cache hourly refresh,
deduplicate _is_quiet_hours, add HTTP timeouts, cache albums in config
flow, change iot_class to local_polling.

Frontend: Make i18n reactive via $state (remove window.location.reload),
add Modal transitions/a11y/Escape key, create ConfirmModal replacing all
confirm() calls, add error handling to all pages, replace Unicode nav
icons with MDI SVGs, add card hover effects, dashboard stat icons, global
focus-visible styles, form slide transitions, mobile responsive bottom
nav, fix password error color, add ~20 i18n keys (EN/RU).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:34:31 +03:00
parent a04d5618d0
commit 381de98c40
39 changed files with 785 additions and 626 deletions

View File

@@ -354,6 +354,7 @@ async def _send_queued_items(
items = queue.get_all()
sent_count = 0
sent_indices = []
for i in indices:
if i >= len(items):
continue
@@ -371,14 +372,16 @@ async def _send_queued_items(
blocking=True,
)
sent_count += 1
sent_indices.append(i)
except Exception:
_LOGGER.exception("Failed to send queued notification %d", i + 1)
# Small delay between notifications to avoid rate limiting
await asyncio.sleep(1)
# Remove sent items from queue (in reverse order to preserve indices)
await queue.async_remove_indices(sorted(indices, reverse=True))
# Only remove successfully sent items (in reverse order to preserve indices)
if sent_indices:
await queue.async_remove_indices(sorted(sent_indices, reverse=True))
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
@@ -419,7 +422,7 @@ async def _async_update_listener(
subentries_data = entry_data["subentries"]
for subentry_data in subentries_data.values():
subentry_data.coordinator.update_scan_interval(new_interval)
subentry_data.coordinator._sync_client = sync_client
subentry_data.coordinator.update_sync_client(sync_client)
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)

View File

@@ -3,7 +3,9 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from datetime import timedelta
from homeassistant.util import dt as dt_util
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -74,7 +76,7 @@ class ImmichAlbumNewAssetsSensor(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
@property
@@ -93,7 +95,7 @@ class ImmichAlbumNewAssetsSensor(
# Check if we're still within the reset window
if self._album_data.last_change_time:
elapsed = datetime.now() - self._album_data.last_change_time
elapsed = dt_util.now() - self._album_data.last_change_time
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
# Auto-reset the flag
self.coordinator.clear_new_assets_flag()

View File

@@ -75,7 +75,7 @@ class ImmichCreateShareLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
@property
@@ -158,7 +158,7 @@ class ImmichDeleteShareLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
@property
@@ -248,7 +248,7 @@ class ImmichCreateProtectedLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
@property
@@ -335,7 +335,7 @@ class ImmichDeleteProtectedLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
@property

View File

@@ -22,7 +22,7 @@ from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
_THUMBNAIL_TIMEOUT = aiohttp.ClientTimeout(total=10)
async def async_setup_entry(
@@ -68,7 +68,7 @@ class ImmichAlbumThumbnailCamera(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
self._cached_image: bytes | None = None
self._last_thumbnail_id: str | None = None
@@ -131,7 +131,7 @@ class ImmichAlbumThumbnailCamera(
)
try:
async with session.get(thumbnail_url, headers=headers) as response:
async with session.get(thumbnail_url, headers=headers, timeout=_THUMBNAIL_TIMEOUT) as response:
if response.status == 200:
self._cached_image = await response.read()
self._last_thumbnail_id = self._album_data.thumbnail_asset_id

View File

@@ -39,13 +39,16 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10)
async def validate_connection(
session: aiohttp.ClientSession, url: str, api_key: str
) -> dict[str, Any]:
"""Validate the Immich connection and return server info."""
headers = {"x-api-key": api_key}
async with session.get(
f"{url.rstrip('/')}/api/server/ping", headers=headers
f"{url.rstrip('/')}/api/server/ping", headers=headers, timeout=_CONNECT_TIMEOUT
) as response:
if response.status == 401:
raise InvalidAuth
@@ -169,23 +172,7 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
url = config_entry.data[CONF_IMMICH_URL]
api_key = config_entry.data[CONF_API_KEY]
# Fetch available albums
session = async_get_clientsession(self.hass)
try:
self._albums = await fetch_albums(session, url, api_key)
except Exception:
_LOGGER.exception("Failed to fetch albums")
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({}),
errors=errors,
)
if not self._albums:
return self.async_abort(reason="no_albums")
if user_input is not None:
if user_input is not None and self._albums:
album_id = user_input[CONF_ALBUM_ID]
# Check if album is already configured
@@ -208,6 +195,23 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
},
)
# Fetch available albums (only when displaying the form)
if not self._albums:
session = async_get_clientsession(self.hass)
try:
self._albums = await fetch_albums(session, url, api_key)
except Exception:
_LOGGER.exception("Failed to fetch albums")
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({}),
errors=errors,
)
if not self._albums:
return self.async_abort(reason="no_albums")
# Get already configured album IDs
configured_albums = set()
for subentry in config_entry.subentries.values():

View File

@@ -3,15 +3,14 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from datetime import timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .storage import ImmichAlbumStorage
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -122,7 +121,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
# Caches managed by the client
self._users_cache: dict[str, str] = {}
self._users_cache_time: float = 0
self._shared_links: list[SharedLinkInfo] = []
self._shared_links_dirty = True
self._server_config_fetched = False
def _get_client(self) -> ImmichClient:
@@ -181,6 +182,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Update the scan interval."""
self.update_interval = timedelta(seconds=scan_interval)
def update_sync_client(self, sync_client: Any | None) -> None:
"""Update the server sync client."""
self._sync_client = sync_client
async def async_refresh_now(self) -> None:
"""Force an immediate refresh."""
await self.async_request_refresh()
@@ -321,6 +326,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
result = await client.create_shared_link(self._album_id, password)
if result:
self._shared_links = await client.get_shared_links(self._album_id)
self._shared_links_dirty = False
return result
async def async_delete_shared_link(self, link_id: str) -> bool:
@@ -329,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
result = await client.delete_shared_link(link_id)
if result:
self._shared_links = await client.get_shared_links(self._album_id)
self._shared_links_dirty = False
return result
async def async_set_shared_link_password(
@@ -339,6 +346,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
result = await client.set_shared_link_password(link_id, password)
if result:
self._shared_links = await client.get_shared_links(self._album_id)
self._shared_links_dirty = False
return result
def clear_new_assets_flag(self) -> None:
@@ -358,12 +366,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
await client.get_server_config()
self._server_config_fetched = True
# Fetch users to resolve owner names
if not self._users_cache:
# Fetch users to resolve owner names (refresh every hour)
import time
if not self._users_cache or (time.monotonic() - self._users_cache_time > 3600):
self._users_cache = await client.get_users()
self._users_cache_time = time.monotonic()
# Fetch shared links (refresh each time as links can change)
self._shared_links = await client.get_shared_links(self._album_id)
# Fetch shared links only when needed (on first load or after mutations)
if self._shared_links_dirty:
self._shared_links = await client.get_shared_links(self._album_id)
self._shared_links_dirty = False
try:
album = await client.get_album(self._album_id, self._users_cache)
@@ -389,7 +401,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
)
if change:
album.has_new_assets = change.added_count > 0
album.last_change_time = datetime.now()
album.last_change_time = dt_util.now()
self._fire_events(change, album)
elif self._persisted_asset_ids is not None:
# First refresh after restart - compare with persisted state
@@ -423,7 +435,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
removed_asset_ids=list(removed_ids),
)
album.has_new_assets = change.added_count > 0
album.last_change_time = datetime.now()
album.last_change_time = dt_util.now()
self._fire_events(change, album)
_LOGGER.info(
"Detected changes during downtime for album '%s': +%d -%d",

View File

@@ -5,7 +5,7 @@
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
"iot_class": "cloud_polling",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": ["immich-watcher-core==0.1.0"],
"version": "2.8.0"

View File

@@ -174,7 +174,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
self._unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
@property
def _album_data(self) -> AlbumData | None:
@@ -239,27 +239,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
)
return {"assets": assets}
@staticmethod
def _is_quiet_hours(start_str: str | None, end_str: str | None) -> bool:
"""Check if current time is within quiet hours."""
from datetime import time as dt_time
from homeassistant.util import dt as dt_util
if not start_str or not end_str:
return False
try:
now = dt_util.now().time()
start_time = dt_time.fromisoformat(start_str)
end_time = dt_time.fromisoformat(end_str)
except ValueError:
return False
if start_time <= end_time:
return start_time <= now < end_time
else:
return now >= start_time or now < end_time
async def async_send_telegram_notification(
self,
chat_id: str,
@@ -280,7 +259,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
) -> ServiceResponse:
"""Send notification to Telegram."""
# Check quiet hours — queue notification if active
if self._is_quiet_hours(quiet_hours_start, quiet_hours_end):
from . import _is_quiet_hours
if _is_quiet_hours(quiet_hours_start, quiet_hours_end):
from . import _register_queue_timers
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
await queue.async_enqueue({

View File

@@ -71,7 +71,7 @@ class ImmichAlbumProtectedPasswordText(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit"
@property

View File

@@ -59,6 +59,24 @@ input, select, textarea {
border-color: var(--color-border);
}
/* Global focus-visible styles for accessibility */
input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 0.25rem;
}
a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 0.25rem;
}
/* Override browser autofill styles in dark mode */
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,

View File

@@ -1,10 +1,11 @@
<script lang="ts">
let { children, class: className = '' } = $props<{
let { children, class: className = '', hover = false } = $props<{
children: import('svelte').Snippet;
class?: string;
hover?: boolean;
}>();
</script>
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {className}">
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {hover ? 'transition-all duration-150 hover:shadow-md hover:-translate-y-px' : ''} {className}">
{@render children()}
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { t } from '$lib/i18n';
let { open = false, title = '', message = '', onconfirm, oncancel } = $props<{
open: boolean;
title?: string;
message?: string;
onconfirm: () => void;
oncancel: () => void;
}>();
</script>
<Modal {open} title={title || t('common.confirm')} onclose={oncancel}>
<p class="text-sm text-[var(--color-muted-foreground)] mb-4">{message}</p>
<div class="flex gap-2 justify-end">
<button onclick={oncancel}
class="px-3 py-1.5 rounded-md text-sm border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
<button onclick={onconfirm}
class="px-3 py-1.5 rounded-md text-sm bg-[var(--color-destructive)] text-white hover:opacity-90 transition-opacity">
{t('common.delete')}
</button>
</div>
</Modal>

View File

@@ -50,8 +50,17 @@
open = false;
search = '';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
open = false;
search = '';
}
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
<div class="inline-block">
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
@@ -65,9 +74,9 @@
</div>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;" onclick={() => { open = false; search = ''; }}></div>
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
role="presentation"
onclick={() => { open = false; search = ''; }}></div>
<div style="{dropdownStyle} width: 20rem;"
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3">

View File

@@ -1,27 +1,50 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
let { open = false, title = '', onclose, children } = $props<{
open: boolean;
title?: string;
onclose: () => void;
children: import('svelte').Snippet;
}>();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5);"
onclick={onclose}
role="presentation"
class="fixed inset-0 z-[9999] flex items-center justify-center"
transition:fade={{ duration: 150 }}
>
<!-- Backdrop -->
<div
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); width: 100%; max-width: 28rem; margin: 1rem; padding: 1.25rem;"
class="absolute inset-0 bg-black/50"
onclick={onclose}
role="presentation"
></div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- Panel -->
<div
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
class="relative bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
transition:fly={{ y: -20, duration: 200 }}
>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">{title}</h3>
<button onclick={onclose}
style="color: var(--color-muted-foreground); font-size: 1.25rem; line-height: 1; cursor: pointer; background: none; border: none; padding: 0.25rem;">
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] text-xl leading-none cursor-pointer bg-transparent border-none p-1 transition-colors"
aria-label="Close">
&times;
</button>
</div>

View File

@@ -53,7 +53,11 @@
"connecting": "Connecting...",
"noServers": "No servers configured yet.",
"delete": "Delete",
"confirmDelete": "Delete this server?"
"confirmDelete": "Delete this server?",
"online": "Online",
"offline": "Offline",
"checking": "Checking...",
"loadError": "Failed to load servers."
},
"trackers": {
"title": "Trackers",
@@ -177,7 +181,8 @@
"private": "Private",
"group": "Group",
"supergroup": "Supergroup",
"channel": "Channel"
"channel": "Channel",
"confirmDelete": "Delete this bot?"
},
"trackingConfig": {
"title": "Tracking Configs",
@@ -211,7 +216,20 @@
"assetType": "Asset type",
"minRating": "Min rating",
"memoryMode": "Memory Mode (On This Day)",
"test": "Test"
"test": "Test",
"confirmDelete": "Delete this tracking config?",
"sortNone": "None",
"sortDate": "Date",
"sortRating": "Rating",
"sortName": "Name",
"orderDesc": "Descending",
"orderAsc": "Ascending",
"albumModePerAlbum": "Per album",
"albumModeCombined": "Combined",
"albumModeRandom": "Random",
"assetTypeAll": "All",
"assetTypePhoto": "Photo",
"assetTypeVideo": "Video"
},
"templateConfig": {
"title": "Template Configs",
@@ -246,7 +264,8 @@
"memoryMode": "Memory mode",
"telegramSettings": "Telegram",
"videoWarning": "Video warning",
"preview": "Preview"
"preview": "Preview",
"confirmDelete": "Delete this template config?"
},
"common": {
"loading": "Loading...",
@@ -258,6 +277,10 @@
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"none": "None",
"noneDefault": "None (default)",
"loadError": "Failed to load data",
"headersInvalid": "Invalid JSON",
"language": "Language",
"theme": "Theme",
"light": "Light",
@@ -268,6 +291,8 @@
"changePassword": "Change Password",
"currentPassword": "Current password",
"newPassword": "New password",
"passwordChanged": "Password changed successfully"
"passwordChanged": "Password changed successfully",
"expand": "Expand",
"collapse": "Collapse"
}
}

View File

@@ -0,0 +1,62 @@
/**
* Reactive i18n module using Svelte 5 $state rune.
* Locale changes automatically propagate to all components using t().
*/
import en from './en.json';
import ru from './ru.json';
export type Locale = 'en' | 'ru';
const translations: Record<Locale, Record<string, any>> = { en, ru };
function detectLocale(): Locale {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('locale') as Locale | null;
if (saved && saved in translations) return saved;
}
if (typeof navigator !== 'undefined') {
const lang = navigator.language.slice(0, 2);
if (lang in translations) return lang as Locale;
}
return 'en';
}
let currentLocale = $state<Locale>(detectLocale());
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(locale: Locale) {
currentLocale = locale;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('locale', locale);
}
}
export function initLocale() {
// No-op: locale is auto-detected at module load via $state.
// Kept for backward compatibility with existing onMount calls.
}
/**
* Get a translated string by dot-separated key.
* Falls back to English if key not found in current locale.
* Reactive: re-evaluates when currentLocale changes.
*/
export function t(key: string): string {
return resolve(translations[currentLocale], key)
?? resolve(translations.en, key)
?? key;
}
function resolve(obj: any, path: string): string | undefined {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = current[part];
}
return typeof current === 'string' ? current : undefined;
}

View File

@@ -1,73 +1,2 @@
/**
* Simple i18n module. Uses plain variable (no $state rune)
* so it works in both SSR and client contexts.
*/
import en from './en.json';
import ru from './ru.json';
export type Locale = 'en' | 'ru';
const translations: Record<Locale, Record<string, any>> = { en, ru };
let currentLocale: Locale = 'en';
// Auto-initialize from localStorage on module load (client-side)
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('locale') as Locale | null;
if (saved && saved in translations) {
currentLocale = saved;
} else if (typeof navigator !== 'undefined') {
const lang = navigator.language.slice(0, 2);
if (lang in translations) {
currentLocale = lang as Locale;
}
}
}
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(locale: Locale) {
currentLocale = locale;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('locale', locale);
}
}
export function initLocale() {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('locale') as Locale | null;
if (saved && saved in translations) {
currentLocale = saved;
return;
}
}
if (typeof navigator !== 'undefined') {
const lang = navigator.language.slice(0, 2);
if (lang in translations) {
currentLocale = lang as Locale;
}
}
}
/**
* Get a translated string by dot-separated key.
* Falls back to English if key not found in current locale.
*/
export function t(key: string): string {
return resolve(translations[currentLocale], key)
?? resolve(translations.en, key)
?? key;
}
function resolve(obj: any, path: string): string | undefined {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = current[part];
}
return typeof current === 'string' ? current : undefined;
}
// Re-export from the .svelte.ts module which supports $state runes
export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';

View File

@@ -53,7 +53,11 @@
"connecting": "Подключение...",
"noServers": "Серверы не настроены.",
"delete": "Удалить",
"confirmDelete": "Удалить этот сервер?"
"confirmDelete": "Удалить этот сервер?",
"online": "В сети",
"offline": "Не в сети",
"checking": "Проверка...",
"loadError": "Не удалось загрузить серверы."
},
"trackers": {
"title": "Трекеры",
@@ -177,7 +181,8 @@
"private": "Личный",
"group": "Группа",
"supergroup": "Супергруппа",
"channel": "Канал"
"channel": "Канал",
"confirmDelete": "Удалить этого бота?"
},
"trackingConfig": {
"title": "Конфигурации отслеживания",
@@ -211,7 +216,20 @@
"assetType": "Тип файлов",
"minRating": "Мин. рейтинг",
"memoryMode": "Воспоминания (В этот день)",
"test": "Тест"
"test": "Тест",
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
"sortNone": "Нет",
"sortDate": "Дата",
"sortRating": "Рейтинг",
"sortName": "Имя",
"orderDesc": "По убыванию",
"orderAsc": "По возрастанию",
"albumModePerAlbum": "По альбомам",
"albumModeCombined": "Объединённый",
"albumModeRandom": "Случайный",
"assetTypeAll": "Все",
"assetTypePhoto": "Фото",
"assetTypeVideo": "Видео"
},
"templateConfig": {
"title": "Конфигурации шаблонов",
@@ -246,7 +264,8 @@
"memoryMode": "Воспоминания",
"telegramSettings": "Telegram",
"videoWarning": "Предупреждение о видео",
"preview": "Предпросмотр"
"preview": "Предпросмотр",
"confirmDelete": "Удалить эту конфигурацию шаблона?"
},
"common": {
"loading": "Загрузка...",
@@ -258,6 +277,10 @@
"confirm": "Подтвердить",
"error": "Ошибка",
"success": "Успешно",
"none": "Нет",
"noneDefault": "Нет (по умолчанию)",
"loadError": "Не удалось загрузить данные",
"headersInvalid": "Невалидный JSON",
"language": "Язык",
"theme": "Тема",
"light": "Светлая",
@@ -268,6 +291,8 @@
"changePassword": "Сменить пароль",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"passwordChanged": "Пароль успешно изменён"
"passwordChanged": "Пароль успешно изменён",
"expand": "Развернуть",
"collapse": "Свернуть"
}
}

View File

@@ -8,6 +8,7 @@
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let { children } = $props();
const auth = getAuth();
@@ -17,45 +18,38 @@
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdMsg = $state('');
let pwdSuccess = $state(false);
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = '';
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.passwordChanged');
pwdSuccess = true;
pwdCurrent = ''; pwdNew = '';
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; }
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; }
}
// Reactive counter to force re-render on locale change
let localeVersion = $state(0);
let collapsed = $state(false);
const navItems = [
{ href: '/', key: 'nav.dashboard', icon: '' },
{ href: '/servers', key: 'nav.servers', icon: '' },
{ href: '/trackers', key: 'nav.trackers', icon: '' },
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '' },
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '' },
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: '' },
{ href: '/targets', key: 'nav.targets', icon: '' },
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
];
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
// Re-derive translations when locale changes
function tt(key: string): string {
void localeVersion; // dependency on reactive counter
return t(key);
}
onMount(async () => {
initLocale();
initTheme();
// Restore sidebar state
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
}
@@ -73,9 +67,6 @@
function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en');
localeVersion++;
// Force full page re-render so child components re-evaluate t() calls
window.location.reload();
}
function toggleSidebar() {
@@ -90,22 +81,22 @@
{@render children()}
{:else if auth.loading}
<div class="min-h-screen flex items-center justify-center">
<p class="text-sm text-[var(--color-muted-foreground)]">{tt('common.loading')}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
</div>
{:else if auth.user}
<div class="flex h-screen">
<!-- Sidebar -->
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200">
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200 max-md:hidden">
<div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
{#if !collapsed}
<div>
<h1 class="text-base font-semibold tracking-tight">{tt('app.name')}</h1>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{tt('app.tagline')}</p>
<h1 class="text-base font-semibold tracking-tight">{t('app.name')}</h1>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{t('app.tagline')}</p>
</div>
{/if}
<button onclick={toggleSidebar}
class="flex items-center justify-center w-8 h-8 rounded-md text-base text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)] transition-colors"
title={collapsed ? 'Expand' : 'Collapse'}>
title={collapsed ? t('common.expand') : t('common.collapse')}>
{collapsed ? '▶' : '◀'}
</button>
</div>
@@ -118,10 +109,10 @@
{page.url.pathname === item.href
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
title={collapsed ? tt(item.key) : ''}
title={collapsed ? t(item.key) : ''}
>
<span class="text-base">{item.icon}</span>
{#if !collapsed}{tt(item.key)}{/if}
<MdiIcon name={item.icon} size={18} />
{#if !collapsed}{t(item.key)}{/if}
</a>
{/each}
{#if auth.isAdmin}
@@ -131,10 +122,10 @@
{page.url.pathname === '/users'
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
title={collapsed ? tt('nav.users') : ''}
title={collapsed ? t('nav.users') : ''}
>
<span class="text-base"></span>
{#if !collapsed}{tt('nav.users')}{/if}
<MdiIcon name="mdiAccountGroup" size={18} />
{#if !collapsed}{t('nav.users')}{/if}
</a>
{/if}
</nav>
@@ -145,12 +136,12 @@
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
<button onclick={toggleLocale}
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={tt('common.language')}>
title={t('common.language')}>
{getLocale().toUpperCase()}
</button>
<button onclick={cycleTheme}
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={tt('common.theme')}>
title={t('common.theme')}>
{theme.resolved === 'dark' ? '🌙' : '☀️'}
</button>
</div>
@@ -160,7 +151,7 @@
{#if collapsed}
<button onclick={logout}
class="w-full flex justify-center py-2 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] rounded hover:bg-[var(--color-muted)] transition-colors"
title={tt('nav.logout')}>
title={t('nav.logout')}>
</button>
{:else}
@@ -172,13 +163,13 @@
</div>
<button onclick={logout}
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={tt('nav.logout')}>
title={t('nav.logout')}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
<button onclick={() => showPasswordForm = true}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
🔑 {tt('common.changePassword')}
🔑 {t('common.changePassword')}
</button>
</div>
{/if}
@@ -186,33 +177,55 @@
</div>
</aside>
<!-- Mobile bottom nav -->
<nav class="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-card)] border-t border-[var(--color-border)] flex justify-around py-1.5">
{#each navItems.slice(0, 5) as item}
<a href={item.href}
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs rounded-md transition-colors
{page.url.pathname === item.href
? 'text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)]'}">
<MdiIcon name={item.icon} size={20} />
</a>
{/each}
<button onclick={logout}
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs text-[var(--color-muted-foreground)]">
<MdiIcon name="mdiLogout" size={20} />
</button>
</nav>
<!-- Main content -->
<main class="flex-1 overflow-auto">
<div class="max-w-5xl mx-auto p-6">
<main class="flex-1 overflow-auto pb-16 md:pb-0">
<div class="max-w-5xl mx-auto p-4 md:p-6">
{@render children()}
</div>
</main>
</div>
{:else}
<!-- Redirect in progress -->
<div class="min-h-screen flex items-center justify-center">
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
</div>
{/if}
<!-- Password change modal (outside flex container) -->
<Modal open={showPasswordForm} title={tt('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; }}>
<!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
<form onsubmit={changePassword} class="space-y-3">
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{tt('common.currentPassword')}</label>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{tt('common.newPassword')}</label>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
<p class="text-sm" style="color: var(--color-success-fg);">{pwdMsg}</p>
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{tt('common.save')}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity">
{t('common.save')}
</button>
</form>
</Modal>

View File

@@ -5,35 +5,79 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let status = $state<any>(null);
let loaded = $state(false);
onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } });
let error = $state('');
onMount(async () => {
try {
status = await api('/status');
} catch (err: any) {
error = err.message || t('common.error');
} finally {
loaded = true;
}
});
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
{#if !loaded}
<Loading lines={4} />
{:else if error}
<Card>
<div class="flex items-center gap-2 text-[var(--color-error-fg)]">
<MdiIcon name="mdiAlertCircle" size={20} />
<p class="text-sm">{error}</p>
</div>
</Card>
{:else if status}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
<p class="text-3xl font-semibold mt-1">{status.servers}</p>
<Card hover>
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
<MdiIcon name="mdiServer" size={22} />
</div>
<div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
<p class="text-2xl font-semibold">{status.servers}</p>
</div>
</div>
</Card>
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
<p class="text-3xl font-semibold mt-1">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
<Card hover>
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
<MdiIcon name="mdiRadar" size={22} />
</div>
<div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
<p class="text-2xl font-semibold">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
</div>
</div>
</Card>
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
<p class="text-3xl font-semibold mt-1">{status.targets}</p>
<Card hover>
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
<MdiIcon name="mdiTarget" size={22} />
</div>
<div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
<p class="text-2xl font-semibold">{status.targets}</p>
</div>
</div>
</Card>
</div>
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
{#if status.recent_events.length === 0}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.noEvents')}</p></Card>
<Card>
<div class="flex flex-col items-center py-4 gap-2 text-[var(--color-muted-foreground)]">
<MdiIcon name="mdiCalendarBlank" size={32} />
<p class="text-sm">{t('dashboard.noEvents')}</p>
</div>
</Card>
{:else}
<Card>
<div class="divide-y divide-[var(--color-border)]">

View File

@@ -39,7 +39,7 @@
<div class="w-full max-w-sm">
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
<div class="flex justify-end gap-1 mb-4">
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); window.location.reload(); }}
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
{getLocale().toUpperCase()}
</button>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,20 +8,28 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let servers = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
let error = $state('');
let loadError = $state('');
let submitting = $state(false);
let loaded = $state(false);
let confirmDelete = $state<any>(null);
let health = $state<Record<number, boolean | null>>({});
onMount(load);
async function load() {
try { servers = await api('/servers'); } catch {} finally { loaded = true; }
try {
servers = await api('/servers');
loadError = '';
} catch (err: any) {
loadError = err.message || t('servers.loadError');
} finally { loaded = true; }
// Ping all servers in background
for (const s of servers) {
health[s.id] = null; // loading
@@ -52,8 +61,14 @@
submitting = false;
}
async function remove(id: number) {
if (!confirm(t('servers.confirmDelete'))) return;
function startDelete(server: any) {
confirmDelete = server;
}
async function doDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
}
</script>
@@ -69,7 +84,14 @@
<Loading />
{:else}
{#if loadError}
<Card class="mb-6">
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
</Card>
{/if}
{#if showForm}
<div transition:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-3">
@@ -95,6 +117,7 @@
</button>
</form>
</Card>
</div>
{/if}
{#if servers.length === 0 && !showForm}
@@ -102,11 +125,11 @@
{:else}
<div class="space-y-3">
{#each servers as server}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
title={health[server.id] === true ? 'Online' : health[server.id] === false ? 'Offline' : 'Checking...'}></span>
title={health[server.id] === true ? t('servers.online') : health[server.id] === false ? t('servers.offline') : t('servers.checking')}></span>
{#if server.icon}<MdiIcon name={server.icon} />{/if}
<div>
<p class="font-medium">{server.name}</p>
@@ -115,7 +138,7 @@
</div>
<div class="flex items-center gap-3">
<button onclick={() => edit(server)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
<button onclick={() => startDelete(server)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
</div>
</div>
</Card>
@@ -124,3 +147,6 @@
{/if}
{/if}
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} />

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,6 +8,7 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let targets = $state<any[]>([]);
let trackingConfigs = $state<any[]>([]);
@@ -22,8 +24,12 @@
tracking_config_id: 0, template_config_id: 0 });
let form = $state(defaultForm());
let error = $state('');
let headersError = $state('');
let testResult = $state('');
let loaded = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<any>(null);
onMount(load);
async function load() {
@@ -31,7 +37,8 @@
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
]);
} catch {} finally { loaded = true; }
loadError = '';
} catch (err: any) { loadError = err.message || t('common.loadError'); } finally { loaded = true; }
}
async function loadBotChats() {
@@ -39,7 +46,7 @@
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
}
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
async function edit(tgt: any) {
formType = tgt.type;
const c = tgt.config || {};
@@ -52,11 +59,11 @@
tracking_config_id: tgt.tracking_config_id ?? 0,
template_config_id: tgt.template_config_id ?? 0,
};
editing = tgt.id; showForm = true;
editing = tgt.id; showTelegramSettings = false; showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
e.preventDefault(); error = ''; headersError = '';
try {
let botToken = form.bot_token;
// Resolve token from registered bot if selected
@@ -64,6 +71,15 @@
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
let parsedHeaders = {};
if (formType === 'webhook' && form.headers) {
try {
parsedHeaders = JSON.parse(form.headers);
} catch {
headersError = t('common.headersInvalid');
return;
}
}
const config = formType === 'telegram'
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
bot_id: form.bot_id || undefined,
@@ -71,7 +87,7 @@
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions }
: { url: form.url, headers: form.headers ? JSON.parse(form.headers) : {}, ai_captions: form.ai_captions };
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
const trkId = form.tracking_config_id || null;
const tplId = form.template_config_id || null;
if (editing) {
@@ -89,7 +105,6 @@
setTimeout(() => testResult = '', 5000);
}
async function remove(id: number) {
if (!confirm(t('targets.confirmDelete'))) return;
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
}
</script>
@@ -103,11 +118,16 @@
{#if !loaded}<Loading />{:else}
{#if loadError}
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
{/if}
{#if testResult}
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes(t('targets.testSent')) ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]'}">{testResult}</div>
{/if}
{#if showForm}
<div transition:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-4">
@@ -163,9 +183,14 @@
{/if}
<!-- Telegram media settings -->
<details class="border border-[var(--color-border)] rounded-md p-3">
<summary class="text-sm font-medium cursor-pointer">{t('targets.telegramSettings')}</summary>
<div class="grid grid-cols-2 gap-3 mt-3">
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div transition:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}</label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
@@ -185,12 +210,18 @@
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
</details>
{/if}
</div>
{:else}
<div>
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if headersError}<p class="text-xs text-[var(--color-destructive)] mt-1">{headersError}</p>{/if}
</div>
{/if}
<!-- Config assignments -->
@@ -198,14 +229,14 @@
<div>
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label>
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0}> None —</option>
<option value={0}> {t('common.none')} —</option>
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
</div>
<div>
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label>
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0}> None (default) —</option>
<option value={0}> {t('common.noneDefault')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
</div>
@@ -216,6 +247,7 @@
<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">{editing ? t('common.save') : t('targets.create')}</button>
</form>
</Card>
</div>
{/if}
{#if targets.length === 0 && !showForm}
@@ -223,7 +255,7 @@
{:else}
<div class="space-y-3">
{#each targets as target}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -238,7 +270,7 @@
<div class="flex items-center gap-3">
<button onclick={() => edit(target)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={() => test(target.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('targets.test')}</button>
<button onclick={() => remove(target.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
<button onclick={() => confirmDelete = target} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
</div>
</div>
</Card>
@@ -247,3 +279,11 @@
{/if}
{/if}
<ConfirmModal
open={!!confirmDelete}
title={t('targets.confirmDelete')}
message={confirmDelete?.name ?? ''}
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
oncancel={() => confirmDelete = null}
/>

View File

@@ -7,6 +7,7 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let bots = $state<any[]>([]);
let loaded = $state(false);
@@ -14,6 +15,7 @@
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
// Per-bot chat lists
let chats = $state<Record<number, any[]>>({});
@@ -21,7 +23,11 @@
let expandedBot = $state<number | null>(null);
onMount(load);
async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } }
async function load() {
try { bots = await api('/telegram-bots'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
@@ -32,9 +38,15 @@
submitting = false;
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
async function loadChats(botId: number) {
@@ -96,7 +108,7 @@
{:else}
<div class="space-y-3">
{#each bots as bot}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -149,3 +161,6 @@
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,12 +8,14 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
let previewSlot = $state('message_assets_added');
let previewResult = $state('');
let previewId = $state<number | null>(null);
@@ -78,7 +81,11 @@
];
onMount(load);
async function load() { try { configs = await api('/template-configs'); } catch {} finally { loaded = true; } }
async function load() {
try { configs = await api('/template-configs'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
@@ -100,9 +107,15 @@
} catch (err: any) { previewResult = `Error: ${err.message}`; }
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
</script>
@@ -116,6 +129,7 @@
{#if !loaded}<Loading />{:else}
{#if showForm}
<div transition:slide>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-5">
@@ -135,7 +149,7 @@
{#each group.slots as slot}
<div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
<textarea bind:value={form[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
<textarea bind:value={(form as any)[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
</div>
{/each}
@@ -148,6 +162,7 @@
</button>
</form>
</Card>
</div>
{/if}
{#if configs.length === 0 && !showForm}
@@ -155,7 +170,7 @@
{:else}
<div class="space-y-3">
{#each configs as config}
<Card>
<Card hover>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
@@ -182,3 +197,6 @@
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,8 +8,10 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let loaded = $state(false);
let loadError = $state('');
let trackers = $state<any[]>([]);
let servers = $state<any[]>([]);
let targets = $state<any[]>([]);
@@ -16,6 +19,12 @@
let showForm = $state(false);
let editing = $state<number | null>(null);
let albumFilter = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let toggling = $state<Record<number, boolean>>({});
let testingPeriodic = $state<Record<number, boolean>>({});
let testingMemory = $state<Record<number, boolean>>({});
let testFeedback = $state<Record<number, string>>({});
const defaultForm = () => ({
name: '', icon: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60,
@@ -25,7 +34,14 @@
onMount(load);
async function load() {
try { [trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]); } catch {} finally { loaded = true; }
loadError = '';
try {
[trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]);
} catch (err: any) {
loadError = err.message || 'Failed to load data';
} finally {
loaded = true;
}
}
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
@@ -41,6 +57,8 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
if (submitting) return;
submitting = true;
try {
if (editing) {
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
@@ -48,14 +66,52 @@
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
} catch (err: any) { error = err.message; } finally { submitting = false; }
}
async function toggle(tracker: any) {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) }); await load();
if (toggling[tracker.id]) return;
toggling[tracker.id] = true;
try {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
} finally { toggling[tracker.id] = false; }
}
async function remove(id: number) {
if (!confirm(t('trackers.confirmDelete'))) return;
try { await api(`/trackers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function startDelete(tracker: any) { confirmDelete = tracker; }
async function doDelete() {
if (!confirmDelete) return;
try {
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
} catch (err: any) { error = err.message; }
confirmDelete = null;
}
async function testPeriodic(tracker: any) {
if (testingPeriodic[tracker.id]) return;
testingPeriodic[tracker.id] = true;
testFeedback[tracker.id] = '';
try {
await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' });
testFeedback[tracker.id] = 'ok';
} catch {
testFeedback[tracker.id] = 'error';
} finally {
testingPeriodic[tracker.id] = false;
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
}
}
async function testMemory(tracker: any) {
if (testingMemory[tracker.id]) return;
testingMemory[tracker.id] = true;
testFeedback[tracker.id] = '';
try {
await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' });
testFeedback[tracker.id] = 'ok';
} catch {
testFeedback[tracker.id] = 'error';
} finally {
testingMemory[tracker.id] = false;
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
}
}
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
@@ -70,7 +126,17 @@
{#if !loaded}
<Loading />
{:else if loadError}
<Card>
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">
{loadError}
</div>
<button onclick={load} class="mt-3 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
{t('common.retry')}
</button>
</Card>
{:else if showForm}
<div transition:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-4">
@@ -127,9 +193,10 @@
</div>
{/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">{editing ? t('common.save') : t('trackers.createTracker')}</button>
<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">{editing ? t('common.save') : t('trackers.createTracker')}</button>
</form>
</Card>
</div>
{/if}
{#if !loaded}
@@ -139,7 +206,7 @@
{:else}
<div class="space-y-3">
{#each trackers as tracker}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -149,20 +216,37 @@
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} target(s)</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} {t('trackers.targets')}</p>
</div>
<div class="flex items-center gap-3">
<button onclick={() => edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}</button>
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Periodic</button>
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Memory</button>
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
<button onclick={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{testingPeriodic[tracker.id] ? '...' : t('trackers.testPeriodic')}
</button>
<button onclick={() => remove(tracker.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
<button onclick={() => testMemory(tracker)} disabled={testingMemory[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{testingMemory[tracker.id] ? '...' : t('trackers.testMemory')}
</button>
{#if testFeedback[tracker.id]}
<span class="text-xs {testFeedback[tracker.id] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-destructive)]'}">
{testFeedback[tracker.id] === 'ok' ? '\u2713' : '\u2717'}
</span>
{/if}
<button onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{toggling[tracker.id] ? '...' : tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
</button>
<button onclick={() => startDelete(tracker)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal
open={!!confirmDelete}
title={t('trackers.delete')}
message={t('trackers.deleteConfirm')}
onconfirm={doDelete}
oncancel={() => confirmDelete = null}
/>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,12 +8,14 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
const defaultForm = () => ({
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
@@ -30,7 +33,11 @@
let form = $state(defaultForm());
onMount(load);
async function load() { try { configs = await api('/tracking-configs'); } catch {} finally { loaded = true; } }
async function load() {
try { configs = await api('/tracking-configs'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) {
@@ -47,9 +54,15 @@
} catch (err: any) { error = err.message; }
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
</script>
@@ -63,6 +76,7 @@
{#if !loaded}<Loading />{:else}
{#if showForm}
<div transition:slide>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-5">
@@ -104,13 +118,13 @@
<div>
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="none">None</option><option value="date">Date</option><option value="rating">Rating</option><option value="name">Name</option>
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
</select>
</div>
<div>
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="descending">Desc</option><option value="ascending">Asc</option>
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
</select>
</div>
</div>
@@ -138,12 +152,12 @@
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</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 text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.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 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')}</label>
@@ -160,12 +174,12 @@
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</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 text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.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 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')}</label>
@@ -178,6 +192,7 @@
</button>
</form>
</Card>
</div>
{/if}
{#if configs.length === 0 && !showForm}
@@ -185,7 +200,7 @@
{:else}
<div class="space-y-3">
{#each configs as config}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -210,3 +225,6 @@
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

View File

@@ -7,6 +7,7 @@
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
const auth = getAuth();
let users = $state<any[]>([]);
@@ -14,35 +15,48 @@
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<any>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
onMount(load);
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); }
catch (err: any) { error = err.message; }
}
async function remove(id: number) {
if (!confirm(t('users.confirmDelete'))) return;
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = '';
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = '';
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000);
} catch (err: any) { resetMsg = err.message; }
resetSuccess = true;
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; }
}
</script>
@@ -81,7 +95,7 @@
<div class="space-y-3">
{#each users as user}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
@@ -101,7 +115,7 @@
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; }}>
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
@@ -109,10 +123,13 @@
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetMsg.includes(t('common.passwordChanged')) ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{t('common.save')}
</button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

View File

@@ -164,7 +164,7 @@ async def _build_context(session: AsyncSession, chat_id: str) -> str:
if trackers:
parts.append(f"Active trackers: {len(trackers)}")
for t in trackers[:5]:
parts.append(f" - {t.name}: {len(t.album_ids)} album(s), events: {', '.join(t.event_types)}")
parts.append(f" - {t.name}: {len(t.album_ids)} album(s)")
result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(5)
@@ -184,15 +184,19 @@ async def _get_summary_data(
"""Fetch data for album summary."""
albums_data: list[dict[str, Any]] = []
servers_result = await session.exec(select(ImmichServer).limit(5))
for server in servers_result.all():
try:
from immich_watcher_core.immich_client import ImmichClient
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
albums = await client.get_albums()
albums_data.extend(albums[:20])
except Exception:
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
servers = servers_result.all()
try:
from immich_watcher_core.immich_client import ImmichClient
async with aiohttp.ClientSession() as http_session:
for server in servers:
try:
client = ImmichClient(http_session, server.url, server.api_key)
albums = await client.get_albums()
albums_data.extend(albums[:20])
except Exception:
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
except Exception:
_LOGGER.debug("Failed to create HTTP session for summary")
events_result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(20)

View File

@@ -1,152 +0,0 @@
"""Scheduled notification job API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import AlbumTracker, ScheduledJob, User
router = APIRouter(prefix="/api/scheduled", tags=["scheduled"])
class ScheduledJobCreate(BaseModel):
tracker_id: int
job_type: str # periodic_summary, scheduled_assets, memory
enabled: bool = True
times: str = "09:00"
interval_days: int = 1
start_date: str = "2025-01-01"
album_mode: str = "per_album"
limit: int = 10
favorite_only: bool = False
asset_type: str = "all"
min_rating: int = 0
order_by: str = "random"
order: str = "descending"
min_date: str | None = None
max_date: str | None = None
message_template: str = ""
class ScheduledJobUpdate(BaseModel):
enabled: bool | None = None
times: str | None = None
interval_days: int | None = None
album_mode: str | None = None
limit: int | None = None
favorite_only: bool | None = None
asset_type: str | None = None
min_rating: int | None = None
order_by: str | None = None
order: str | None = None
min_date: str | None = None
max_date: str | None = None
message_template: str | None = None
@router.get("")
async def list_jobs(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all scheduled jobs for the current user's trackers."""
trackers = await session.exec(
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
)
tracker_ids = [t.id for t in trackers.all()]
if not tracker_ids:
return []
result = await session.exec(
select(ScheduledJob).where(ScheduledJob.tracker_id.in_(tracker_ids))
)
return [_job_response(j) for j in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_job(
body: ScheduledJobCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a scheduled notification job."""
# Verify tracker ownership
tracker = await session.get(AlbumTracker, body.tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
if body.job_type not in ("periodic_summary", "scheduled_assets", "memory"):
raise HTTPException(status_code=400, detail="Invalid job_type")
job = ScheduledJob(**body.model_dump())
session.add(job)
await session.commit()
await session.refresh(job)
return _job_response(job)
@router.put("/{job_id}")
async def update_job(
job_id: int,
body: ScheduledJobUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a scheduled job."""
job = await _get_user_job(session, job_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(job, field, value)
session.add(job)
await session.commit()
await session.refresh(job)
return _job_response(job)
@router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_job(
job_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a scheduled job."""
job = await _get_user_job(session, job_id, user.id)
await session.delete(job)
await session.commit()
def _job_response(j: ScheduledJob) -> dict:
return {
"id": j.id,
"tracker_id": j.tracker_id,
"job_type": j.job_type,
"enabled": j.enabled,
"times": j.times,
"interval_days": j.interval_days,
"start_date": j.start_date,
"album_mode": j.album_mode,
"limit": j.limit,
"favorite_only": j.favorite_only,
"asset_type": j.asset_type,
"min_rating": j.min_rating,
"order_by": j.order_by,
"order": j.order,
"min_date": j.min_date,
"max_date": j.max_date,
"message_template": j.message_template,
"created_at": j.created_at.isoformat(),
}
async def _get_user_job(
session: AsyncSession, job_id: int, user_id: int
) -> ScheduledJob:
job = await session.get(ScheduledJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracker = await session.get(AlbumTracker, job.tracker_id)
if not tracker or tracker.user_id != user_id:
raise HTTPException(status_code=404, detail="Job not found")
return job

View File

@@ -104,10 +104,28 @@ async def update_server(
server = await _get_user_server(session, server_id, user.id)
if body.name is not None:
server.name = body.name
url_changed = body.url is not None and body.url != server.url
key_changed = body.api_key is not None and body.api_key != server.api_key
if body.url is not None:
server.url = body.url
if body.api_key is not None:
server.api_key = body.api_key
# Re-validate and refresh external_domain when URL or API key changes
if url_changed or key_changed:
try:
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
if not await client.ping():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot connect to Immich server at {server.url}",
)
server.external_domain = await client.get_server_config()
except aiohttp.ClientError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
session.add(server)
await session.commit()
await session.refresh(server)

View File

@@ -52,12 +52,9 @@ class SyncTrackerResponse(BaseModel):
id: int
name: str
server_url: str
server_api_key: str
album_ids: list[str]
event_types: list[str]
scan_interval: int
enabled: bool
template_body: str | None = None
targets: list[dict] = []
@@ -84,40 +81,60 @@ async def get_sync_trackers(
)
trackers = result.all()
# Batch-load servers and targets to avoid N+1 queries
server_ids = {t.server_id for t in trackers}
all_target_ids = {tid for t in trackers for tid in t.target_ids}
servers_result = await session.exec(
select(ImmichServer).where(ImmichServer.id.in_(server_ids))
)
servers_map = {s.id: s for s in servers_result.all()}
targets_result = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(all_target_ids))
)
targets_map = {t.id: t for t in targets_result.all()}
responses = []
for tracker in trackers:
# Fetch server details
server = await session.get(ImmichServer, tracker.server_id)
server = servers_map.get(tracker.server_id)
if not server:
continue
# Fetch target configs
targets = []
for target_id in tracker.target_ids:
target = await session.get(NotificationTarget, target_id)
target = targets_map.get(target_id)
if target:
targets.append({
"type": target.type,
"name": target.name,
"config": target.config,
"config": _safe_target_config(target),
})
responses.append(SyncTrackerResponse(
id=tracker.id,
name=tracker.name,
server_url=server.url,
server_api_key=server.api_key,
album_ids=tracker.album_ids,
event_types=[], # Event types now on tracking configs
scan_interval=tracker.scan_interval,
enabled=tracker.enabled,
template_body=None,
targets=targets,
))
return responses
def _safe_target_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
if "bot_token" in config:
token = config["bot_token"]
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
if "api_key" in config:
config["api_key"] = "***"
return config
@router.post("/templates/{template_id}/render")
async def render_template(
template_id: int,

View File

@@ -92,6 +92,10 @@ async def update_target(
target.name = body.name
if body.config is not None:
target.config = body.config
if body.tracking_config_id is not None:
target.tracking_config_id = body.tracking_config_id
if body.template_config_id is not None:
target.template_config_id = body.template_config_id
session.add(target)
await session.commit()
await session.refresh(target)

View File

@@ -97,8 +97,9 @@ async def get_bot_token(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get the full bot token (for internal use by targets)."""
"""Get the full bot token (used by frontend to construct target config)."""
bot = await _get_user_bot(session, bot_id, user.id)
# Token is returned only to the authenticated owner
return {"token": bot.token}

View File

@@ -1,145 +0,0 @@
"""Message template management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import jinja2
from jinja2.sandbox import SandboxedEnvironment
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import MessageTemplate, User
router = APIRouter(prefix="/api/templates", tags=["templates"])
# Sample data for template preview
_SAMPLE_CONTEXT = {
"album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123",
"added_count": 3,
"removed_count": 0,
"change_type": "assets_added",
"people": ["Alice", "Bob"],
"added_assets": [
{"filename": "IMG_001.jpg", "type": "IMAGE", "owner": "Alice", "created_at": "2024-03-19T10:30:00Z"},
{"filename": "IMG_002.jpg", "type": "IMAGE", "owner": "Bob", "created_at": "2024-03-19T11:00:00Z"},
{"filename": "VID_003.mp4", "type": "VIDEO", "owner": "Alice", "created_at": "2024-03-19T11:30:00Z"},
],
}
class TemplateCreate(BaseModel):
name: str
body: str
is_default: bool = False
class TemplateUpdate(BaseModel):
name: str | None = None
body: str | None = None
is_default: bool | None = None
@router.get("")
async def list_templates(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all templates for the current user."""
result = await session.exec(
select(MessageTemplate).where(MessageTemplate.user_id == user.id)
)
return [
{"id": t.id, "name": t.name, "body": t.body, "is_default": t.is_default, "created_at": t.created_at.isoformat()}
for t in result.all()
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_template(
body: TemplateCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a new message template."""
template = MessageTemplate(
user_id=user.id,
name=body.name,
body=body.body,
is_default=body.is_default,
)
session.add(template)
await session.commit()
await session.refresh(template)
return {"id": template.id, "name": template.name}
@router.get("/{template_id}")
async def get_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific template."""
template = await _get_user_template(session, template_id, user.id)
return {"id": template.id, "name": template.name, "body": template.body, "is_default": template.is_default}
@router.put("/{template_id}")
async def update_template(
template_id: int,
body: TemplateUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a template."""
template = await _get_user_template(session, template_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(template, field, value)
session.add(template)
await session.commit()
await session.refresh(template)
return {"id": template.id, "name": template.name}
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a template."""
template = await _get_user_template(session, template_id, user.id)
await session.delete(template)
await session.commit()
@router.post("/{template_id}/preview")
async def preview_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Render a template with sample data."""
template = await _get_user_template(session, template_id, user.id)
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template.body)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
except jinja2.TemplateError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Template error: {e}",
)
async def _get_user_template(
session: AsyncSession, template_id: int, user_id: int
) -> MessageTemplate:
template = await session.get(MessageTemplate, template_id)
if not template or template.user_id != user_id:
raise HTTPException(status_code=404, detail="Template not found")
return template

View File

@@ -1,6 +1,6 @@
"""Album tracker management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -150,7 +150,7 @@ async def test_memory(
@router.get("/{tracker_id}/history")
async def tracker_history(
tracker_id: int,
limit: int = 20,
limit: int = Query(default=20, ge=1, le=500),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):

View File

@@ -1,6 +1,7 @@
"""Server configuration from environment variables."""
from pathlib import Path
from typing import Any
from pydantic_settings import BaseSettings
@@ -11,8 +12,16 @@ class Settings(BaseSettings):
data_dir: Path = Path("/data")
database_url: str = "" # Computed from data_dir if empty
# JWT
# JWT (MUST be overridden via IMMICH_WATCHER_SECRET_KEY env var)
secret_key: str = "change-me-in-production"
def model_post_init(self, __context: Any) -> None:
if self.secret_key == "change-me-in-production" and not self.debug:
import logging
logging.getLogger(__name__).critical(
"SECURITY: Using default secret_key in non-debug mode! "
"Set IMMICH_WATCHER_SECRET_KEY environment variable."
)
access_token_expire_minutes: int = 60
refresh_token_expire_days: int = 30

View File

@@ -56,11 +56,11 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS for frontend dev server
# CORS: restrict to same-origin in production, allow all in debug mode
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=["*"] if settings.debug else [],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

View File

@@ -166,16 +166,28 @@ async def _check_album(
)
session.add(event_log)
# Send notifications to each target, filtered by its tracking config
for target_id in target_ids:
target = await session.get(NotificationTarget, target_id)
if not target:
continue
# Batch-load targets, tracking configs, and template configs
targets_result = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
)
targets = targets_result.all()
# Check target's tracking config for event filtering
tracking_config = None
if target.tracking_config_id:
tracking_config = await session.get(TrackingConfig, target.tracking_config_id)
tc_ids = {t.tracking_config_id for t in targets if t.tracking_config_id}
tmpl_ids = {t.template_config_id for t in targets if t.template_config_id}
tracking_configs_map: dict[int, TrackingConfig] = {}
if tc_ids:
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
tracking_configs_map = {tc.id: tc for tc in tc_result.all()}
template_configs_map: dict[int, TemplateConfig] = {}
if tmpl_ids:
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
template_configs_map = {tc.id: tc for tc in tmpl_result.all()}
# Send notifications to each target, filtered by its tracking config
for target in targets:
tracking_config = tracking_configs_map.get(target.tracking_config_id) if target.tracking_config_id else None
if tracking_config:
# Filter by event type
@@ -193,16 +205,13 @@ async def _check_album(
if not should_notify:
continue
# Get target's template config
template_config = None
if target.template_config_id:
template_config = await session.get(TemplateConfig, target.template_config_id)
template_config = template_configs_map.get(target.template_config_id) if target.template_config_id else None
try:
use_ai = target.config.get("ai_captions", False)
await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
except Exception:
_LOGGER.exception("Failed to send notification to target %d", target_id)
_LOGGER.exception("Failed to send notification to target %d", target.id)
return {
"album_id": album_id,