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

View File

@@ -3,7 +3,9 @@
from __future__ import annotations from __future__ import annotations
import logging 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 ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@@ -74,7 +76,7 @@ class ImmichAlbumNewAssetsSensor(
self._album_id = subentry.data[CONF_ALBUM_ID] self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") 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" self._attr_unique_id = f"{unique_id_prefix}_new_assets"
@property @property
@@ -93,7 +95,7 @@ class ImmichAlbumNewAssetsSensor(
# Check if we're still within the reset window # Check if we're still within the reset window
if self._album_data.last_change_time: 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): if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
# Auto-reset the flag # Auto-reset the flag
self.coordinator.clear_new_assets_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_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") 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" self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
@property @property
@@ -158,7 +158,7 @@ class ImmichDeleteShareLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID] self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") 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" self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
@property @property
@@ -248,7 +248,7 @@ class ImmichCreateProtectedLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID] self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") 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" self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
@property @property
@@ -335,7 +335,7 @@ class ImmichDeleteProtectedLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID] self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") 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" self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
@property @property

View File

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

View File

@@ -39,13 +39,16 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10)
async def validate_connection( async def validate_connection(
session: aiohttp.ClientSession, url: str, api_key: str session: aiohttp.ClientSession, url: str, api_key: str
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate the Immich connection and return server info.""" """Validate the Immich connection and return server info."""
headers = {"x-api-key": api_key} headers = {"x-api-key": api_key}
async with session.get( 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: ) as response:
if response.status == 401: if response.status == 401:
raise InvalidAuth raise InvalidAuth
@@ -169,23 +172,7 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
url = config_entry.data[CONF_IMMICH_URL] url = config_entry.data[CONF_IMMICH_URL]
api_key = config_entry.data[CONF_API_KEY] api_key = config_entry.data[CONF_API_KEY]
# Fetch available albums if user_input is not None and 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")
if user_input is not None:
album_id = user_input[CONF_ALBUM_ID] album_id = user_input[CONF_ALBUM_ID]
# Check if album is already configured # 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 # Get already configured album IDs
configured_albums = set() configured_albums = set()
for subentry in config_entry.subentries.values(): for subentry in config_entry.subentries.values():

View File

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

View File

@@ -5,7 +5,7 @@
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher", "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", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": ["immich-watcher-core==0.1.0"], "requirements": ["immich-watcher-core==0.1.0"],
"version": "2.8.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_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") 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 @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
@@ -239,27 +239,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
) )
return {"assets": assets} 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( async def async_send_telegram_notification(
self, self,
chat_id: str, chat_id: str,
@@ -280,7 +259,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
) -> ServiceResponse: ) -> ServiceResponse:
"""Send notification to Telegram.""" """Send notification to Telegram."""
# Check quiet hours — queue notification if active # 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 from . import _register_queue_timers
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"] queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
await queue.async_enqueue({ await queue.async_enqueue({

View File

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

View File

@@ -59,6 +59,24 @@ input, select, textarea {
border-color: var(--color-border); 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 */ /* Override browser autofill styles in dark mode */
[data-theme="dark"] input:-webkit-autofill, [data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover, [data-theme="dark"] input:-webkit-autofill:hover,

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
let { children, class: className = '' } = $props<{ let { children, class: className = '', hover = false } = $props<{
children: import('svelte').Snippet; children: import('svelte').Snippet;
class?: string; class?: string;
hover?: boolean;
}>(); }>();
</script> </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()} {@render children()}
</div> </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; open = false;
search = ''; search = '';
} }
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
open = false;
search = '';
}
}
</script> </script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
<div class="inline-block"> <div class="inline-block">
<button type="button" bind:this={buttonEl} onclick={toggleOpen} <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"> 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> </div>
{#if open} {#if open}
<!-- svelte-ignore a11y_click_events_have_key_events --> <div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
<!-- svelte-ignore a11y_no_static_element_interactions --> role="presentation"
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;" onclick={() => { open = false; search = ''; }}></div> onclick={() => { open = false; search = ''; }}></div>
<div style="{dropdownStyle} width: 20rem;" <div style="{dropdownStyle} width: 20rem;"
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3"> 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"> <script lang="ts">
import { fade, fly } from 'svelte/transition';
let { open = false, title = '', onclose, children } = $props<{ let { open = false, title = '', onclose, children } = $props<{
open: boolean; open: boolean;
title?: string; title?: string;
onclose: () => void; onclose: () => void;
children: import('svelte').Snippet; children: import('svelte').Snippet;
}>(); }>();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script> </script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open} {#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <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);" role="presentation"
onclick={onclose} class="fixed inset-0 z-[9999] flex items-center justify-center"
transition:fade={{ duration: 150 }}
> >
<!-- Backdrop -->
<div <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()} 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;"> <div class="flex items-center justify-between mb-4">
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3> <h3 class="text-lg font-semibold">{title}</h3>
<button onclick={onclose} <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; &times;
</button> </button>
</div> </div>

View File

@@ -53,7 +53,11 @@
"connecting": "Connecting...", "connecting": "Connecting...",
"noServers": "No servers configured yet.", "noServers": "No servers configured yet.",
"delete": "Delete", "delete": "Delete",
"confirmDelete": "Delete this server?" "confirmDelete": "Delete this server?",
"online": "Online",
"offline": "Offline",
"checking": "Checking...",
"loadError": "Failed to load servers."
}, },
"trackers": { "trackers": {
"title": "Trackers", "title": "Trackers",
@@ -177,7 +181,8 @@
"private": "Private", "private": "Private",
"group": "Group", "group": "Group",
"supergroup": "Supergroup", "supergroup": "Supergroup",
"channel": "Channel" "channel": "Channel",
"confirmDelete": "Delete this bot?"
}, },
"trackingConfig": { "trackingConfig": {
"title": "Tracking Configs", "title": "Tracking Configs",
@@ -211,7 +216,20 @@
"assetType": "Asset type", "assetType": "Asset type",
"minRating": "Min rating", "minRating": "Min rating",
"memoryMode": "Memory Mode (On This Day)", "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": { "templateConfig": {
"title": "Template Configs", "title": "Template Configs",
@@ -246,7 +264,8 @@
"memoryMode": "Memory mode", "memoryMode": "Memory mode",
"telegramSettings": "Telegram", "telegramSettings": "Telegram",
"videoWarning": "Video warning", "videoWarning": "Video warning",
"preview": "Preview" "preview": "Preview",
"confirmDelete": "Delete this template config?"
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
@@ -258,6 +277,10 @@
"confirm": "Confirm", "confirm": "Confirm",
"error": "Error", "error": "Error",
"success": "Success", "success": "Success",
"none": "None",
"noneDefault": "None (default)",
"loadError": "Failed to load data",
"headersInvalid": "Invalid JSON",
"language": "Language", "language": "Language",
"theme": "Theme", "theme": "Theme",
"light": "Light", "light": "Light",
@@ -268,6 +291,8 @@
"changePassword": "Change Password", "changePassword": "Change Password",
"currentPassword": "Current password", "currentPassword": "Current password",
"newPassword": "New 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 @@
/** // Re-export from the .svelte.ts module which supports $state runes
* Simple i18n module. Uses plain variable (no $state rune) export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
* 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;
}

View File

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

View File

@@ -8,6 +8,7 @@
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n'; import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte'; import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let { children } = $props(); let { children } = $props();
const auth = getAuth(); const auth = getAuth();
@@ -17,45 +18,38 @@
let pwdCurrent = $state(''); let pwdCurrent = $state('');
let pwdNew = $state(''); let pwdNew = $state('');
let pwdMsg = $state(''); let pwdMsg = $state('');
let pwdSuccess = $state(false);
async function changePassword(e: SubmitEvent) { async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = ''; e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
try { try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) }); await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.passwordChanged'); pwdMsg = t('common.passwordChanged');
pwdSuccess = true;
pwdCurrent = ''; pwdNew = ''; pwdCurrent = ''; pwdNew = '';
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000); setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
} catch (err: any) { pwdMsg = err.message; } } 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); let collapsed = $state(false);
const navItems = [ const navItems = [
{ href: '/', key: 'nav.dashboard', icon: '' }, { href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/servers', key: 'nav.servers', icon: '' }, { href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
{ href: '/trackers', key: 'nav.trackers', icon: '' }, { href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '' }, { href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '' }, { href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: '' }, { href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
{ href: '/targets', key: 'nav.targets', icon: '' }, { href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
]; ];
const isAuthPage = $derived( const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup' 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 () => { onMount(async () => {
initLocale(); initLocale();
initTheme(); initTheme();
// Restore sidebar state
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true'; collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
} }
@@ -73,9 +67,6 @@
function toggleLocale() { function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en'); setLocale(getLocale() === 'en' ? 'ru' : 'en');
localeVersion++;
// Force full page re-render so child components re-evaluate t() calls
window.location.reload();
} }
function toggleSidebar() { function toggleSidebar() {
@@ -90,22 +81,22 @@
{@render children()} {@render children()}
{:else if auth.loading} {:else if auth.loading}
<div class="min-h-screen flex items-center justify-center"> <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> </div>
{:else if auth.user} {:else if auth.user}
<div class="flex h-screen"> <div class="flex h-screen">
<!-- Sidebar --> <!-- 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'}"> <div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
{#if !collapsed} {#if !collapsed}
<div> <div>
<h1 class="text-base font-semibold tracking-tight">{tt('app.name')}</h1> <h1 class="text-base font-semibold tracking-tight">{t('app.name')}</h1>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{tt('app.tagline')}</p> <p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{t('app.tagline')}</p>
</div> </div>
{/if} {/if}
<button onclick={toggleSidebar} <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" 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 ? '▶' : '◀'} {collapsed ? '▶' : '◀'}
</button> </button>
</div> </div>
@@ -118,10 +109,10 @@
{page.url.pathname === item.href {page.url.pathname === item.href
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium' ? '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)]'}" : '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> <MdiIcon name={item.icon} size={18} />
{#if !collapsed}{tt(item.key)}{/if} {#if !collapsed}{t(item.key)}{/if}
</a> </a>
{/each} {/each}
{#if auth.isAdmin} {#if auth.isAdmin}
@@ -131,10 +122,10 @@
{page.url.pathname === '/users' {page.url.pathname === '/users'
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium' ? '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)]'}" : '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> <MdiIcon name="mdiAccountGroup" size={18} />
{#if !collapsed}{tt('nav.users')}{/if} {#if !collapsed}{t('nav.users')}{/if}
</a> </a>
{/if} {/if}
</nav> </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'}"> <div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
<button onclick={toggleLocale} <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" 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()} {getLocale().toUpperCase()}
</button> </button>
<button onclick={cycleTheme} <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" 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' ? '🌙' : '☀️'} {theme.resolved === 'dark' ? '🌙' : '☀️'}
</button> </button>
</div> </div>
@@ -160,7 +151,7 @@
{#if collapsed} {#if collapsed}
<button onclick={logout} <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" 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> </button>
{:else} {:else}
@@ -172,13 +163,13 @@
</div> </div>
<button onclick={logout} <button onclick={logout}
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors" 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> <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> </button>
</div> </div>
<button onclick={() => showPasswordForm = true} <button onclick={() => showPasswordForm = true}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1"> class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
🔑 {tt('common.changePassword')} 🔑 {t('common.changePassword')}
</button> </button>
</div> </div>
{/if} {/if}
@@ -186,33 +177,55 @@
</div> </div>
</aside> </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 content -->
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto pb-16 md:pb-0">
<div class="max-w-5xl mx-auto p-6"> <div class="max-w-5xl mx-auto p-4 md:p-6">
{@render children()} {@render children()}
</div> </div>
</main> </main>
</div> </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} {/if}
<!-- Password change modal (outside flex container) --> <!-- Password change modal -->
<Modal open={showPasswordForm} title={tt('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; }}> <Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
<form onsubmit={changePassword} class="space-y-3"> <form onsubmit={changePassword} class="space-y-3">
<div> <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 <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)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
<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 <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)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
{#if pwdMsg} {#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} {/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"> <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">
{tt('common.save')} {t('common.save')}
</button> </button>
</form> </form>
</Modal> </Modal>

View File

@@ -5,35 +5,79 @@
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let status = $state<any>(null); let status = $state<any>(null);
let loaded = $state(false); 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> </script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} /> <PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
{#if !loaded} {#if !loaded}
<Loading lines={4} /> <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} {:else if status}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<Card> <Card hover>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p> <div class="flex items-center gap-3">
<p class="text-3xl font-semibold mt-1">{status.servers}</p> <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>
<Card> <Card hover>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p> <div class="flex items-center gap-3">
<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> <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>
<Card> <Card hover>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p> <div class="flex items-center gap-3">
<p class="text-3xl font-semibold mt-1">{status.targets}</p> <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> </Card>
</div> </div>
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3> <h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
{#if status.recent_events.length === 0} {#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} {:else}
<Card> <Card>
<div class="divide-y divide-[var(--color-border)]"> <div class="divide-y divide-[var(--color-border)]">

View File

@@ -39,7 +39,7 @@
<div class="w-full max-w-sm"> <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="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
<div class="flex justify-end gap-1 mb-4"> <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)]"> class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
{getLocale().toUpperCase()} {getLocale().toUpperCase()}
</button> </button>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,20 +8,28 @@
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let servers = $state<any[]>([]); let servers = $state<any[]>([]);
let showForm = $state(false); let showForm = $state(false);
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' }); let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
let error = $state(''); let error = $state('');
let loadError = $state('');
let submitting = $state(false); let submitting = $state(false);
let loaded = $state(false); let loaded = $state(false);
let confirmDelete = $state<any>(null);
let health = $state<Record<number, boolean | null>>({}); let health = $state<Record<number, boolean | null>>({});
onMount(load); onMount(load);
async function 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 // Ping all servers in background
for (const s of servers) { for (const s of servers) {
health[s.id] = null; // loading health[s.id] = null; // loading
@@ -52,8 +61,14 @@
submitting = false; submitting = false;
} }
async function remove(id: number) { function startDelete(server: any) {
if (!confirm(t('servers.confirmDelete'))) return; 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; } try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
} }
</script> </script>
@@ -69,7 +84,14 @@
<Loading /> <Loading />
{:else} {: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} {#if showForm}
<div transition:slide={{ duration: 200 }}>
<Card class="mb-6"> <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} {#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"> <form onsubmit={save} class="space-y-3">
@@ -95,6 +117,7 @@
</button> </button>
</form> </form>
</Card> </Card>
</div>
{/if} {/if}
{#if servers.length === 0 && !showForm} {#if servers.length === 0 && !showForm}
@@ -102,11 +125,11 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each servers as server} {#each servers as server}
<Card> <Card hover>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <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'}" <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} {#if server.icon}<MdiIcon name={server.icon} />{/if}
<div> <div>
<p class="font-medium">{server.name}</p> <p class="font-medium">{server.name}</p>
@@ -115,7 +138,7 @@
</div> </div>
<div class="flex items-center gap-3"> <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={() => 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>
</div> </div>
</Card> </Card>
@@ -124,3 +147,6 @@
{/if} {/if}
{/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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,6 +8,7 @@
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let targets = $state<any[]>([]); let targets = $state<any[]>([]);
let trackingConfigs = $state<any[]>([]); let trackingConfigs = $state<any[]>([]);
@@ -22,8 +24,12 @@
tracking_config_id: 0, template_config_id: 0 }); tracking_config_id: 0, template_config_id: 0 });
let form = $state(defaultForm()); let form = $state(defaultForm());
let error = $state(''); let error = $state('');
let headersError = $state('');
let testResult = $state(''); let testResult = $state('');
let loaded = $state(false); let loaded = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<any>(null);
onMount(load); onMount(load);
async function load() { async function load() {
@@ -31,7 +37,8 @@
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([ [targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots') 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() { async function loadBotChats() {
@@ -39,7 +46,7 @@
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {} 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) { async function edit(tgt: any) {
formType = tgt.type; formType = tgt.type;
const c = tgt.config || {}; const c = tgt.config || {};
@@ -52,11 +59,11 @@
tracking_config_id: tgt.tracking_config_id ?? 0, tracking_config_id: tgt.tracking_config_id ?? 0,
template_config_id: tgt.template_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) { async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; e.preventDefault(); error = ''; headersError = '';
try { try {
let botToken = form.bot_token; let botToken = form.bot_token;
// Resolve token from registered bot if selected // Resolve token from registered bot if selected
@@ -64,6 +71,15 @@
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`); const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.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' const config = formType === 'telegram'
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id, ? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
bot_id: form.bot_id || undefined, bot_id: form.bot_id || undefined,
@@ -71,7 +87,7 @@
media_delay: form.media_delay, max_asset_size: form.max_asset_size, 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, disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions } 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 trkId = form.tracking_config_id || null;
const tplId = form.template_config_id || null; const tplId = form.template_config_id || null;
if (editing) { if (editing) {
@@ -89,7 +105,6 @@
setTimeout(() => testResult = '', 5000); setTimeout(() => testResult = '', 5000);
} }
async function remove(id: number) { 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; } try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
} }
</script> </script>
@@ -103,11 +118,16 @@
{#if !loaded}<Loading />{:else} {#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} {#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> <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}
{#if showForm} {#if showForm}
<div transition:slide={{ duration: 200 }}>
<Card class="mb-6"> <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} {#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"> <form onsubmit={save} class="space-y-4">
@@ -163,9 +183,14 @@
{/if} {/if}
<!-- Telegram media settings --> <!-- Telegram media settings -->
<details class="border border-[var(--color-border)] rounded-md p-3"> <div class="border border-[var(--color-border)] rounded-md p-3">
<summary class="text-sm font-medium cursor-pointer">{t('targets.telegramSettings')}</summary> <button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
<div class="grid grid-cols-2 gap-3 mt-3"> 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> <div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}</label> <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)]" /> <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.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> <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> </div>
</details> {/if}
</div>
{:else} {:else}
<div> <div>
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label> <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)]" /> <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>
<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} {/if}
<!-- Config assignments --> <!-- Config assignments -->
@@ -198,14 +229,14 @@
<div> <div>
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label> <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)]"> <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} {#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select> </select>
</div> </div>
<div> <div>
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label> <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)]"> <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} {#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select> </select>
</div> </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> <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> </form>
</Card> </Card>
</div>
{/if} {/if}
{#if targets.length === 0 && !showForm} {#if targets.length === 0 && !showForm}
@@ -223,7 +255,7 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each targets as target} {#each targets as target}
<Card> <Card hover>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -238,7 +270,7 @@
<div class="flex items-center gap-3"> <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={() => 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={() => 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>
</div> </div>
</Card> </Card>
@@ -247,3 +279,11 @@
{/if} {/if}
{/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 Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let bots = $state<any[]>([]); let bots = $state<any[]>([]);
let loaded = $state(false); let loaded = $state(false);
@@ -14,6 +15,7 @@
let form = $state({ name: '', icon: '', token: '' }); let form = $state({ name: '', icon: '', token: '' });
let error = $state(''); let error = $state('');
let submitting = $state(false); let submitting = $state(false);
let confirmDelete = $state<any>(null);
// Per-bot chat lists // Per-bot chat lists
let chats = $state<Record<number, any[]>>({}); let chats = $state<Record<number, any[]>>({});
@@ -21,7 +23,11 @@
let expandedBot = $state<number | null>(null); let expandedBot = $state<number | null>(null);
onMount(load); 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) { async function create(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true; e.preventDefault(); error = ''; submitting = true;
@@ -32,9 +38,15 @@
submitting = false; submitting = false;
} }
async function remove(id: number) { function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return; confirmDelete = {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; } 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) { async function loadChats(botId: number) {
@@ -96,7 +108,7 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each bots as bot} {#each bots as bot}
<Card> <Card hover>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -149,3 +161,6 @@
{/if} {/if}
{/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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,12 +8,14 @@
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let configs = $state<any[]>([]); let configs = $state<any[]>([]);
let loaded = $state(false); let loaded = $state(false);
let showForm = $state(false); let showForm = $state(false);
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let error = $state(''); let error = $state('');
let confirmDelete = $state<any>(null);
let previewSlot = $state('message_assets_added'); let previewSlot = $state('message_assets_added');
let previewResult = $state(''); let previewResult = $state('');
let previewId = $state<number | null>(null); let previewId = $state<number | null>(null);
@@ -78,7 +81,11 @@
]; ];
onMount(load); 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 openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; 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}`; } } catch (err: any) { previewResult = `Error: ${err.message}`; }
} }
async function remove(id: number) { function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return; confirmDelete = {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; } id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
} }
</script> </script>
@@ -116,6 +129,7 @@
{#if !loaded}<Loading />{:else} {#if !loaded}<Loading />{:else}
{#if showForm} {#if showForm}
<div transition:slide>
<Card class="mb-6"> <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} {#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"> <form onsubmit={save} class="space-y-5">
@@ -135,7 +149,7 @@
{#each group.slots as slot} {#each group.slots as slot}
<div> <div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label> <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> class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
</div> </div>
{/each} {/each}
@@ -148,6 +162,7 @@
</button> </button>
</form> </form>
</Card> </Card>
</div>
{/if} {/if}
{#if configs.length === 0 && !showForm} {#if configs.length === 0 && !showForm}
@@ -155,7 +170,7 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each configs as config} {#each configs as config}
<Card> <Card hover>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -182,3 +197,6 @@
{/if} {/if}
{/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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,8 +8,10 @@
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let loaded = $state(false); let loaded = $state(false);
let loadError = $state('');
let trackers = $state<any[]>([]); let trackers = $state<any[]>([]);
let servers = $state<any[]>([]); let servers = $state<any[]>([]);
let targets = $state<any[]>([]); let targets = $state<any[]>([]);
@@ -16,6 +19,12 @@
let showForm = $state(false); let showForm = $state(false);
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let albumFilter = $state(''); 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 = () => ({ const defaultForm = () => ({
name: '', icon: '', server_id: 0, album_ids: [] as string[], name: '', icon: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60, target_ids: [] as number[], scan_interval: 60,
@@ -25,7 +34,14 @@
onMount(load); onMount(load);
async function 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`); } 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) { async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; e.preventDefault(); error = '';
if (submitting) return;
submitting = true;
try { try {
if (editing) { if (editing) {
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) }); await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
@@ -48,14 +66,52 @@
await api('/trackers', { method: 'POST', body: JSON.stringify(form) }); await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
} }
showForm = false; editing = null; await load(); 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) { 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) { function startDelete(tracker: any) { confirmDelete = tracker; }
if (!confirm(t('trackers.confirmDelete'))) return; async function doDelete() {
try { await api(`/trackers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; } 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 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]; } 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} {#if !loaded}
<Loading /> <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} {:else if showForm}
<div transition:slide={{ duration: 200 }}>
<Card class="mb-6"> <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} {#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"> <form onsubmit={save} class="space-y-4">
@@ -127,9 +193,10 @@
</div> </div>
{/if} {/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> </form>
</Card> </Card>
</div>
{/if} {/if}
{#if !loaded} {#if !loaded}
@@ -139,7 +206,7 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each trackers as tracker} {#each trackers as tracker}
<Card> <Card hover>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -149,20 +216,37 @@
{tracker.enabled ? t('trackers.active') : t('trackers.paused')} {tracker.enabled ? t('trackers.active') : t('trackers.paused')}
</span> </span>
</div> </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>
<div class="flex items-center gap-3"> <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={() => 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}/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={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
<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> {testingPeriodic[tracker.id] ? '...' : t('trackers.testPeriodic')}
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
</button> </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>
</div> </div>
</Card> </Card>
{/each} {/each}
</div> </div>
{/if} {/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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,12 +8,14 @@
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte'; import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let configs = $state<any[]>([]); let configs = $state<any[]>([]);
let loaded = $state(false); let loaded = $state(false);
let showForm = $state(false); let showForm = $state(false);
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let error = $state(''); let error = $state('');
let confirmDelete = $state<any>(null);
const defaultForm = () => ({ const defaultForm = () => ({
name: '', icon: '', track_assets_added: true, track_assets_removed: false, name: '', icon: '', track_assets_added: true, track_assets_removed: false,
@@ -30,7 +33,11 @@
let form = $state(defaultForm()); let form = $state(defaultForm());
onMount(load); 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 openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) { function edit(c: any) {
@@ -47,9 +54,15 @@
} catch (err: any) { error = err.message; } } catch (err: any) { error = err.message; }
} }
async function remove(id: number) { function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return; confirmDelete = {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; } id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
} }
</script> </script>
@@ -63,6 +76,7 @@
{#if !loaded}<Loading />{:else} {#if !loaded}<Loading />{:else}
{#if showForm} {#if showForm}
<div transition:slide>
<Card class="mb-6"> <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} {#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"> <form onsubmit={save} class="space-y-5">
@@ -104,13 +118,13 @@
<div> <div>
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label> <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)]"> <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> </select>
</div> </div>
<div> <div>
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label> <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)]"> <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> </select>
</div> </div>
</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.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> <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)]"> <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> </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.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> <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)]"> <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> </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> <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> <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.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> <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)]"> <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> </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.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> <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)]"> <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> </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> <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> <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> </button>
</form> </form>
</Card> </Card>
</div>
{/if} {/if}
{#if configs.length === 0 && !showForm} {#if configs.length === 0 && !showForm}
@@ -185,7 +200,7 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each configs as config} {#each configs as config}
<Card> <Card hover>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -210,3 +225,6 @@
{/if} {/if}
{/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 Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
const auth = getAuth(); const auth = getAuth();
let users = $state<any[]>([]); let users = $state<any[]>([]);
@@ -14,35 +15,48 @@
let form = $state({ username: '', password: '', role: 'user' }); let form = $state({ username: '', password: '', role: 'user' });
let error = $state(''); let error = $state('');
let loaded = $state(false); let loaded = $state(false);
let confirmDelete = $state<any>(null);
// Admin reset password // Admin reset password
let resetUserId = $state<number | null>(null); let resetUserId = $state<number | null>(null);
let resetUsername = $state(''); let resetUsername = $state('');
let resetPassword = $state(''); let resetPassword = $state('');
let resetMsg = $state(''); let resetMsg = $state('');
let resetSuccess = $state(false);
onMount(load); 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) { async function create(e: SubmitEvent) {
e.preventDefault(); error = ''; e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); } 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; } catch (err: any) { error = err.message; }
} }
async function remove(id: number) { function remove(id: number) {
if (!confirm(t('users.confirmDelete'))) return; confirmDelete = {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); } 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) { 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) { async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; e.preventDefault(); resetMsg = ''; resetSuccess = false;
try { try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) }); await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged'); resetMsg = t('common.passwordChanged');
setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000); resetSuccess = true;
} catch (err: any) { resetMsg = err.message; } setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; }
} }
</script> </script>
@@ -81,7 +95,7 @@
<div class="space-y-3"> <div class="space-y-3">
{#each users as user} {#each users as user}
<Card> <Card hover>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium">{user.username}</p> <p class="font-medium">{user.username}</p>
@@ -101,7 +115,7 @@
{/if} {/if}
<!-- Admin reset password modal --> <!-- 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"> <form onsubmit={resetUserPassword} class="space-y-3">
<div> <div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label> <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)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
{#if resetMsg} {#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} {/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"> <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')} {t('common.save')}
</button> </button>
</form> </form>
</Modal> </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: if trackers:
parts.append(f"Active trackers: {len(trackers)}") parts.append(f"Active trackers: {len(trackers)}")
for t in trackers[:5]: 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( result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(5) select(EventLog).order_by(EventLog.created_at.desc()).limit(5)
@@ -184,15 +184,19 @@ async def _get_summary_data(
"""Fetch data for album summary.""" """Fetch data for album summary."""
albums_data: list[dict[str, Any]] = [] albums_data: list[dict[str, Any]] = []
servers_result = await session.exec(select(ImmichServer).limit(5)) servers_result = await session.exec(select(ImmichServer).limit(5))
for server in servers_result.all(): servers = servers_result.all()
try: try:
from immich_watcher_core.immich_client import ImmichClient from immich_watcher_core.immich_client import ImmichClient
async with aiohttp.ClientSession() as http_session: async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key) for server in servers:
albums = await client.get_albums() try:
albums_data.extend(albums[:20]) client = ImmichClient(http_session, server.url, server.api_key)
except Exception: albums = await client.get_albums()
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url) 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( events_result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(20) 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) server = await _get_user_server(session, server_id, user.id)
if body.name is not None: if body.name is not None:
server.name = body.name 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: if body.url is not None:
server.url = body.url server.url = body.url
if body.api_key is not None: if body.api_key is not None:
server.api_key = body.api_key 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) session.add(server)
await session.commit() await session.commit()
await session.refresh(server) await session.refresh(server)

View File

@@ -52,12 +52,9 @@ class SyncTrackerResponse(BaseModel):
id: int id: int
name: str name: str
server_url: str server_url: str
server_api_key: str
album_ids: list[str] album_ids: list[str]
event_types: list[str]
scan_interval: int scan_interval: int
enabled: bool enabled: bool
template_body: str | None = None
targets: list[dict] = [] targets: list[dict] = []
@@ -84,40 +81,60 @@ async def get_sync_trackers(
) )
trackers = result.all() 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 = [] responses = []
for tracker in trackers: for tracker in trackers:
# Fetch server details server = servers_map.get(tracker.server_id)
server = await session.get(ImmichServer, tracker.server_id)
if not server: if not server:
continue continue
# Fetch target configs
targets = [] targets = []
for target_id in tracker.target_ids: for target_id in tracker.target_ids:
target = await session.get(NotificationTarget, target_id) target = targets_map.get(target_id)
if target: if target:
targets.append({ targets.append({
"type": target.type, "type": target.type,
"name": target.name, "name": target.name,
"config": target.config, "config": _safe_target_config(target),
}) })
responses.append(SyncTrackerResponse( responses.append(SyncTrackerResponse(
id=tracker.id, id=tracker.id,
name=tracker.name, name=tracker.name,
server_url=server.url, server_url=server.url,
server_api_key=server.api_key,
album_ids=tracker.album_ids, album_ids=tracker.album_ids,
event_types=[], # Event types now on tracking configs
scan_interval=tracker.scan_interval, scan_interval=tracker.scan_interval,
enabled=tracker.enabled, enabled=tracker.enabled,
template_body=None,
targets=targets, targets=targets,
)) ))
return responses 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") @router.post("/templates/{template_id}/render")
async def render_template( async def render_template(
template_id: int, template_id: int,

View File

@@ -92,6 +92,10 @@ async def update_target(
target.name = body.name target.name = body.name
if body.config is not None: if body.config is not None:
target.config = body.config 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) session.add(target)
await session.commit() await session.commit()
await session.refresh(target) await session.refresh(target)

View File

@@ -97,8 +97,9 @@ async def get_bot_token(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), 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) bot = await _get_user_bot(session, bot_id, user.id)
# Token is returned only to the authenticated owner
return {"token": bot.token} 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.""" """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 pydantic import BaseModel
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -150,7 +150,7 @@ async def test_memory(
@router.get("/{tracker_id}/history") @router.get("/{tracker_id}/history")
async def tracker_history( async def tracker_history(
tracker_id: int, tracker_id: int,
limit: int = 20, limit: int = Query(default=20, ge=1, le=500),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):

View File

@@ -1,6 +1,7 @@
"""Server configuration from environment variables.""" """Server configuration from environment variables."""
from pathlib import Path from pathlib import Path
from typing import Any
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -11,8 +12,16 @@ class Settings(BaseSettings):
data_dir: Path = Path("/data") data_dir: Path = Path("/data")
database_url: str = "" # Computed from data_dir if empty 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" 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 access_token_expire_minutes: int = 60
refresh_token_expire_days: int = 30 refresh_token_expire_days: int = 30

View File

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

View File

@@ -166,16 +166,28 @@ async def _check_album(
) )
session.add(event_log) session.add(event_log)
# Send notifications to each target, filtered by its tracking config # Batch-load targets, tracking configs, and template configs
for target_id in target_ids: targets_result = await session.exec(
target = await session.get(NotificationTarget, target_id) select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
if not target: )
continue targets = targets_result.all()
# Check target's tracking config for event filtering tc_ids = {t.tracking_config_id for t in targets if t.tracking_config_id}
tracking_config = None tmpl_ids = {t.template_config_id for t in targets if t.template_config_id}
if target.tracking_config_id:
tracking_config = await session.get(TrackingConfig, target.tracking_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: if tracking_config:
# Filter by event type # Filter by event type
@@ -193,16 +205,13 @@ async def _check_album(
if not should_notify: if not should_notify:
continue continue
# Get target's template config template_config = template_configs_map.get(target.template_config_id) if target.template_config_id else None
template_config = None
if target.template_config_id:
template_config = await session.get(TemplateConfig, target.template_config_id)
try: try:
use_ai = target.config.get("ai_captions", False) use_ai = target.config.get("ai_captions", False)
await send_notification(target, event_data, template_config, use_ai_caption=use_ai) await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
except Exception: 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 { return {
"album_id": album_id, "album_id": album_id,