diff --git a/custom_components/immich_album_watcher/__init__.py b/custom_components/immich_album_watcher/__init__.py
index 53f8e25..b272357 100644
--- a/custom_components/immich_album_watcher/__init__.py
+++ b/custom_components/immich_album_watcher/__init__.py
@@ -354,6 +354,7 @@ async def _send_queued_items(
items = queue.get_all()
sent_count = 0
+ sent_indices = []
for i in indices:
if i >= len(items):
continue
@@ -371,14 +372,16 @@ async def _send_queued_items(
blocking=True,
)
sent_count += 1
+ sent_indices.append(i)
except Exception:
_LOGGER.exception("Failed to send queued notification %d", i + 1)
# Small delay between notifications to avoid rate limiting
await asyncio.sleep(1)
- # Remove sent items from queue (in reverse order to preserve indices)
- await queue.async_remove_indices(sorted(indices, reverse=True))
+ # Only remove successfully sent items (in reverse order to preserve indices)
+ if sent_indices:
+ await queue.async_remove_indices(sorted(sent_indices, reverse=True))
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
@@ -419,7 +422,7 @@ async def _async_update_listener(
subentries_data = entry_data["subentries"]
for subentry_data in subentries_data.values():
subentry_data.coordinator.update_scan_interval(new_interval)
- subentry_data.coordinator._sync_client = sync_client
+ subentry_data.coordinator.update_sync_client(sync_client)
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)
diff --git a/custom_components/immich_album_watcher/binary_sensor.py b/custom_components/immich_album_watcher/binary_sensor.py
index 0f7c08b..fb26c48 100644
--- a/custom_components/immich_album_watcher/binary_sensor.py
+++ b/custom_components/immich_album_watcher/binary_sensor.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import logging
-from datetime import datetime, timedelta
+from datetime import timedelta
+
+from homeassistant.util import dt as dt_util
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -74,7 +76,7 @@ class ImmichAlbumNewAssetsSensor(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
@property
@@ -93,7 +95,7 @@ class ImmichAlbumNewAssetsSensor(
# Check if we're still within the reset window
if self._album_data.last_change_time:
- elapsed = datetime.now() - self._album_data.last_change_time
+ elapsed = dt_util.now() - self._album_data.last_change_time
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
# Auto-reset the flag
self.coordinator.clear_new_assets_flag()
diff --git a/custom_components/immich_album_watcher/button.py b/custom_components/immich_album_watcher/button.py
index 953efd2..da67d73 100644
--- a/custom_components/immich_album_watcher/button.py
+++ b/custom_components/immich_album_watcher/button.py
@@ -75,7 +75,7 @@ class ImmichCreateShareLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
@property
@@ -158,7 +158,7 @@ class ImmichDeleteShareLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
@property
@@ -248,7 +248,7 @@ class ImmichCreateProtectedLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
@property
@@ -335,7 +335,7 @@ class ImmichDeleteProtectedLinkButton(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
@property
diff --git a/custom_components/immich_album_watcher/camera.py b/custom_components/immich_album_watcher/camera.py
index 43eeeb7..d6042b4 100644
--- a/custom_components/immich_album_watcher/camera.py
+++ b/custom_components/immich_album_watcher/camera.py
@@ -22,7 +22,7 @@ from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__)
-SCAN_INTERVAL = timedelta(seconds=60)
+_THUMBNAIL_TIMEOUT = aiohttp.ClientTimeout(total=10)
async def async_setup_entry(
@@ -68,7 +68,7 @@ class ImmichAlbumThumbnailCamera(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
self._cached_image: bytes | None = None
self._last_thumbnail_id: str | None = None
@@ -131,7 +131,7 @@ class ImmichAlbumThumbnailCamera(
)
try:
- async with session.get(thumbnail_url, headers=headers) as response:
+ async with session.get(thumbnail_url, headers=headers, timeout=_THUMBNAIL_TIMEOUT) as response:
if response.status == 200:
self._cached_image = await response.read()
self._last_thumbnail_id = self._album_data.thumbnail_asset_id
diff --git a/custom_components/immich_album_watcher/config_flow.py b/custom_components/immich_album_watcher/config_flow.py
index e7b4d7f..82387ad 100644
--- a/custom_components/immich_album_watcher/config_flow.py
+++ b/custom_components/immich_album_watcher/config_flow.py
@@ -39,13 +39,16 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10)
+
+
async def validate_connection(
session: aiohttp.ClientSession, url: str, api_key: str
) -> dict[str, Any]:
"""Validate the Immich connection and return server info."""
headers = {"x-api-key": api_key}
async with session.get(
- f"{url.rstrip('/')}/api/server/ping", headers=headers
+ f"{url.rstrip('/')}/api/server/ping", headers=headers, timeout=_CONNECT_TIMEOUT
) as response:
if response.status == 401:
raise InvalidAuth
@@ -169,23 +172,7 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
url = config_entry.data[CONF_IMMICH_URL]
api_key = config_entry.data[CONF_API_KEY]
- # Fetch available albums
- session = async_get_clientsession(self.hass)
- try:
- self._albums = await fetch_albums(session, url, api_key)
- except Exception:
- _LOGGER.exception("Failed to fetch albums")
- errors["base"] = "cannot_connect"
- return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema({}),
- errors=errors,
- )
-
- if not self._albums:
- return self.async_abort(reason="no_albums")
-
- if user_input is not None:
+ if user_input is not None and self._albums:
album_id = user_input[CONF_ALBUM_ID]
# Check if album is already configured
@@ -208,6 +195,23 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
},
)
+ # Fetch available albums (only when displaying the form)
+ if not self._albums:
+ session = async_get_clientsession(self.hass)
+ try:
+ self._albums = await fetch_albums(session, url, api_key)
+ except Exception:
+ _LOGGER.exception("Failed to fetch albums")
+ errors["base"] = "cannot_connect"
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema({}),
+ errors=errors,
+ )
+
+ if not self._albums:
+ return self.async_abort(reason="no_albums")
+
# Get already configured album IDs
configured_albums = set()
for subentry in config_entry.subentries.values():
diff --git a/custom_components/immich_album_watcher/coordinator.py b/custom_components/immich_album_watcher/coordinator.py
index 3ff8a67..577b077 100644
--- a/custom_components/immich_album_watcher/coordinator.py
+++ b/custom_components/immich_album_watcher/coordinator.py
@@ -3,15 +3,14 @@
from __future__ import annotations
import logging
-from datetime import datetime, timedelta
+from datetime import timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .storage import ImmichAlbumStorage
-import aiohttp
-
from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -122,7 +121,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
# Caches managed by the client
self._users_cache: dict[str, str] = {}
+ self._users_cache_time: float = 0
self._shared_links: list[SharedLinkInfo] = []
+ self._shared_links_dirty = True
self._server_config_fetched = False
def _get_client(self) -> ImmichClient:
@@ -181,6 +182,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Update the scan interval."""
self.update_interval = timedelta(seconds=scan_interval)
+ def update_sync_client(self, sync_client: Any | None) -> None:
+ """Update the server sync client."""
+ self._sync_client = sync_client
+
async def async_refresh_now(self) -> None:
"""Force an immediate refresh."""
await self.async_request_refresh()
@@ -321,6 +326,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
result = await client.create_shared_link(self._album_id, password)
if result:
self._shared_links = await client.get_shared_links(self._album_id)
+ self._shared_links_dirty = False
return result
async def async_delete_shared_link(self, link_id: str) -> bool:
@@ -329,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
result = await client.delete_shared_link(link_id)
if result:
self._shared_links = await client.get_shared_links(self._album_id)
+ self._shared_links_dirty = False
return result
async def async_set_shared_link_password(
@@ -339,6 +346,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
result = await client.set_shared_link_password(link_id, password)
if result:
self._shared_links = await client.get_shared_links(self._album_id)
+ self._shared_links_dirty = False
return result
def clear_new_assets_flag(self) -> None:
@@ -358,12 +366,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
await client.get_server_config()
self._server_config_fetched = True
- # Fetch users to resolve owner names
- if not self._users_cache:
+ # Fetch users to resolve owner names (refresh every hour)
+ import time
+ if not self._users_cache or (time.monotonic() - self._users_cache_time > 3600):
self._users_cache = await client.get_users()
+ self._users_cache_time = time.monotonic()
- # Fetch shared links (refresh each time as links can change)
- self._shared_links = await client.get_shared_links(self._album_id)
+ # Fetch shared links only when needed (on first load or after mutations)
+ if self._shared_links_dirty:
+ self._shared_links = await client.get_shared_links(self._album_id)
+ self._shared_links_dirty = False
try:
album = await client.get_album(self._album_id, self._users_cache)
@@ -389,7 +401,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
)
if change:
album.has_new_assets = change.added_count > 0
- album.last_change_time = datetime.now()
+ album.last_change_time = dt_util.now()
self._fire_events(change, album)
elif self._persisted_asset_ids is not None:
# First refresh after restart - compare with persisted state
@@ -423,7 +435,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
removed_asset_ids=list(removed_ids),
)
album.has_new_assets = change.added_count > 0
- album.last_change_time = datetime.now()
+ album.last_change_time = dt_util.now()
self._fire_events(change, album)
_LOGGER.info(
"Detected changes during downtime for album '%s': +%d -%d",
diff --git a/custom_components/immich_album_watcher/manifest.json b/custom_components/immich_album_watcher/manifest.json
index 1507659..047b6ef 100644
--- a/custom_components/immich_album_watcher/manifest.json
+++ b/custom_components/immich_album_watcher/manifest.json
@@ -5,7 +5,7 @@
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
- "iot_class": "cloud_polling",
+ "iot_class": "local_polling",
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": ["immich-watcher-core==0.1.0"],
"version": "2.8.0"
diff --git a/custom_components/immich_album_watcher/sensor.py b/custom_components/immich_album_watcher/sensor.py
index 6c1c2d1..c9b1486 100644
--- a/custom_components/immich_album_watcher/sensor.py
+++ b/custom_components/immich_album_watcher/sensor.py
@@ -174,7 +174,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- self._unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ self._unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
@property
def _album_data(self) -> AlbumData | None:
@@ -239,27 +239,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
)
return {"assets": assets}
- @staticmethod
- def _is_quiet_hours(start_str: str | None, end_str: str | None) -> bool:
- """Check if current time is within quiet hours."""
- from datetime import time as dt_time
- from homeassistant.util import dt as dt_util
-
- if not start_str or not end_str:
- return False
-
- try:
- now = dt_util.now().time()
- start_time = dt_time.fromisoformat(start_str)
- end_time = dt_time.fromisoformat(end_str)
- except ValueError:
- return False
-
- if start_time <= end_time:
- return start_time <= now < end_time
- else:
- return now >= start_time or now < end_time
-
async def async_send_telegram_notification(
self,
chat_id: str,
@@ -280,7 +259,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
) -> ServiceResponse:
"""Send notification to Telegram."""
# Check quiet hours — queue notification if active
- if self._is_quiet_hours(quiet_hours_start, quiet_hours_end):
+ from . import _is_quiet_hours
+ if _is_quiet_hours(quiet_hours_start, quiet_hours_end):
from . import _register_queue_timers
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
await queue.async_enqueue({
diff --git a/custom_components/immich_album_watcher/text.py b/custom_components/immich_album_watcher/text.py
index fdffa96..7e8a415 100644
--- a/custom_components/immich_album_watcher/text.py
+++ b/custom_components/immich_album_watcher/text.py
@@ -71,7 +71,7 @@ class ImmichAlbumProtectedPasswordText(
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
- unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
+ unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit"
@property
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 5aef0c0..7668dd3 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -59,6 +59,24 @@ input, select, textarea {
border-color: var(--color-border);
}
+/* Global focus-visible styles for accessibility */
+input:focus-visible, select:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+button:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+ border-radius: 0.25rem;
+}
+
+a:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+ border-radius: 0.25rem;
+}
+
/* Override browser autofill styles in dark mode */
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte
index 9bf4981..f66f700 100644
--- a/frontend/src/lib/components/Card.svelte
+++ b/frontend/src/lib/components/Card.svelte
@@ -1,10 +1,11 @@
-
+
{@render children()}
diff --git a/frontend/src/lib/components/ConfirmModal.svelte b/frontend/src/lib/components/ConfirmModal.svelte
new file mode 100644
index 0000000..1dbdb02
--- /dev/null
+++ b/frontend/src/lib/components/ConfirmModal.svelte
@@ -0,0 +1,26 @@
+
+
+
+ {message}
+
+
+
+
+
diff --git a/frontend/src/lib/components/IconPicker.svelte b/frontend/src/lib/components/IconPicker.svelte
index 465e730..0524730 100644
--- a/frontend/src/lib/components/IconPicker.svelte
+++ b/frontend/src/lib/components/IconPicker.svelte
@@ -50,8 +50,17 @@
open = false;
search = '';
}
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape' && open) {
+ open = false;
+ search = '';
+ }
+ }
+
+
{#if open}
-
-
-
{ open = false; search = ''; }}>
+
{ open = false; search = ''; }}>
diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte
index bff498b..7b6dc78 100644
--- a/frontend/src/lib/components/Modal.svelte
+++ b/frontend/src/lib/components/Modal.svelte
@@ -1,27 +1,50 @@
+
+
{#if open}
-
-
+
+
+
+
+
e.stopPropagation()}
+ onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
+ transition:fly={{ y: -20, duration: 200 }}
>
-
-
{title}
+
+
{title}
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 1b1092b..b951af2 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -53,7 +53,11 @@
"connecting": "Connecting...",
"noServers": "No servers configured yet.",
"delete": "Delete",
- "confirmDelete": "Delete this server?"
+ "confirmDelete": "Delete this server?",
+ "online": "Online",
+ "offline": "Offline",
+ "checking": "Checking...",
+ "loadError": "Failed to load servers."
},
"trackers": {
"title": "Trackers",
@@ -177,7 +181,8 @@
"private": "Private",
"group": "Group",
"supergroup": "Supergroup",
- "channel": "Channel"
+ "channel": "Channel",
+ "confirmDelete": "Delete this bot?"
},
"trackingConfig": {
"title": "Tracking Configs",
@@ -211,7 +216,20 @@
"assetType": "Asset type",
"minRating": "Min rating",
"memoryMode": "Memory Mode (On This Day)",
- "test": "Test"
+ "test": "Test",
+ "confirmDelete": "Delete this tracking config?",
+ "sortNone": "None",
+ "sortDate": "Date",
+ "sortRating": "Rating",
+ "sortName": "Name",
+ "orderDesc": "Descending",
+ "orderAsc": "Ascending",
+ "albumModePerAlbum": "Per album",
+ "albumModeCombined": "Combined",
+ "albumModeRandom": "Random",
+ "assetTypeAll": "All",
+ "assetTypePhoto": "Photo",
+ "assetTypeVideo": "Video"
},
"templateConfig": {
"title": "Template Configs",
@@ -246,7 +264,8 @@
"memoryMode": "Memory mode",
"telegramSettings": "Telegram",
"videoWarning": "Video warning",
- "preview": "Preview"
+ "preview": "Preview",
+ "confirmDelete": "Delete this template config?"
},
"common": {
"loading": "Loading...",
@@ -258,6 +277,10 @@
"confirm": "Confirm",
"error": "Error",
"success": "Success",
+ "none": "None",
+ "noneDefault": "None (default)",
+ "loadError": "Failed to load data",
+ "headersInvalid": "Invalid JSON",
"language": "Language",
"theme": "Theme",
"light": "Light",
@@ -268,6 +291,8 @@
"changePassword": "Change Password",
"currentPassword": "Current password",
"newPassword": "New password",
- "passwordChanged": "Password changed successfully"
+ "passwordChanged": "Password changed successfully",
+ "expand": "Expand",
+ "collapse": "Collapse"
}
}
diff --git a/frontend/src/lib/i18n/index.svelte.ts b/frontend/src/lib/i18n/index.svelte.ts
new file mode 100644
index 0000000..918209b
--- /dev/null
+++ b/frontend/src/lib/i18n/index.svelte.ts
@@ -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
> = { 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(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;
+}
diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts
index 2545c6d..85a03ec 100644
--- a/frontend/src/lib/i18n/index.ts
+++ b/frontend/src/lib/i18n/index.ts
@@ -1,73 +1,2 @@
-/**
- * Simple i18n module. Uses plain variable (no $state rune)
- * so it works in both SSR and client contexts.
- */
-
-import en from './en.json';
-import ru from './ru.json';
-
-export type Locale = 'en' | 'ru';
-
-const translations: Record> = { en, ru };
-
-let currentLocale: Locale = 'en';
-
-// Auto-initialize from localStorage on module load (client-side)
-if (typeof localStorage !== 'undefined') {
- const saved = localStorage.getItem('locale') as Locale | null;
- if (saved && saved in translations) {
- currentLocale = saved;
- } else if (typeof navigator !== 'undefined') {
- const lang = navigator.language.slice(0, 2);
- if (lang in translations) {
- currentLocale = lang as Locale;
- }
- }
-}
-
-export function getLocale(): Locale {
- return currentLocale;
-}
-
-export function setLocale(locale: Locale) {
- currentLocale = locale;
- if (typeof localStorage !== 'undefined') {
- localStorage.setItem('locale', locale);
- }
-}
-
-export function initLocale() {
- if (typeof localStorage !== 'undefined') {
- const saved = localStorage.getItem('locale') as Locale | null;
- if (saved && saved in translations) {
- currentLocale = saved;
- return;
- }
- }
- if (typeof navigator !== 'undefined') {
- const lang = navigator.language.slice(0, 2);
- if (lang in translations) {
- currentLocale = lang as Locale;
- }
- }
-}
-
-/**
- * Get a translated string by dot-separated key.
- * Falls back to English if key not found in current locale.
- */
-export function t(key: string): string {
- return resolve(translations[currentLocale], key)
- ?? resolve(translations.en, key)
- ?? key;
-}
-
-function resolve(obj: any, path: string): string | undefined {
- const parts = path.split('.');
- let current = obj;
- for (const part of parts) {
- if (current == null || typeof current !== 'object') return undefined;
- current = current[part];
- }
- return typeof current === 'string' ? current : undefined;
-}
+// Re-export from the .svelte.ts module which supports $state runes
+export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json
index d95dc17..1ab04af 100644
--- a/frontend/src/lib/i18n/ru.json
+++ b/frontend/src/lib/i18n/ru.json
@@ -53,7 +53,11 @@
"connecting": "Подключение...",
"noServers": "Серверы не настроены.",
"delete": "Удалить",
- "confirmDelete": "Удалить этот сервер?"
+ "confirmDelete": "Удалить этот сервер?",
+ "online": "В сети",
+ "offline": "Не в сети",
+ "checking": "Проверка...",
+ "loadError": "Не удалось загрузить серверы."
},
"trackers": {
"title": "Трекеры",
@@ -177,7 +181,8 @@
"private": "Личный",
"group": "Группа",
"supergroup": "Супергруппа",
- "channel": "Канал"
+ "channel": "Канал",
+ "confirmDelete": "Удалить этого бота?"
},
"trackingConfig": {
"title": "Конфигурации отслеживания",
@@ -211,7 +216,20 @@
"assetType": "Тип файлов",
"minRating": "Мин. рейтинг",
"memoryMode": "Воспоминания (В этот день)",
- "test": "Тест"
+ "test": "Тест",
+ "confirmDelete": "Удалить эту конфигурацию отслеживания?",
+ "sortNone": "Нет",
+ "sortDate": "Дата",
+ "sortRating": "Рейтинг",
+ "sortName": "Имя",
+ "orderDesc": "По убыванию",
+ "orderAsc": "По возрастанию",
+ "albumModePerAlbum": "По альбомам",
+ "albumModeCombined": "Объединённый",
+ "albumModeRandom": "Случайный",
+ "assetTypeAll": "Все",
+ "assetTypePhoto": "Фото",
+ "assetTypeVideo": "Видео"
},
"templateConfig": {
"title": "Конфигурации шаблонов",
@@ -246,7 +264,8 @@
"memoryMode": "Воспоминания",
"telegramSettings": "Telegram",
"videoWarning": "Предупреждение о видео",
- "preview": "Предпросмотр"
+ "preview": "Предпросмотр",
+ "confirmDelete": "Удалить эту конфигурацию шаблона?"
},
"common": {
"loading": "Загрузка...",
@@ -258,6 +277,10 @@
"confirm": "Подтвердить",
"error": "Ошибка",
"success": "Успешно",
+ "none": "Нет",
+ "noneDefault": "Нет (по умолчанию)",
+ "loadError": "Не удалось загрузить данные",
+ "headersInvalid": "Невалидный JSON",
"language": "Язык",
"theme": "Тема",
"light": "Светлая",
@@ -268,6 +291,8 @@
"changePassword": "Сменить пароль",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
- "passwordChanged": "Пароль успешно изменён"
+ "passwordChanged": "Пароль успешно изменён",
+ "expand": "Развернуть",
+ "collapse": "Свернуть"
}
}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index a816595..4646158 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -8,6 +8,7 @@
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte';
+ import MdiIcon from '$lib/components/MdiIcon.svelte';
let { children } = $props();
const auth = getAuth();
@@ -17,45 +18,38 @@
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdMsg = $state('');
+ let pwdSuccess = $state(false);
async function changePassword(e: SubmitEvent) {
- e.preventDefault(); pwdMsg = '';
+ e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.passwordChanged');
+ pwdSuccess = true;
pwdCurrent = ''; pwdNew = '';
- setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
- } catch (err: any) { pwdMsg = err.message; }
+ setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
+ } catch (err: any) { pwdMsg = err.message; pwdSuccess = false; }
}
- // Reactive counter to force re-render on locale change
- let localeVersion = $state(0);
let collapsed = $state(false);
const navItems = [
- { href: '/', key: 'nav.dashboard', icon: '⊞' },
- { href: '/servers', key: 'nav.servers', icon: '⬡' },
- { href: '/trackers', key: 'nav.trackers', icon: '◎' },
- { href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '⚙' },
- { href: '/template-configs', key: 'nav.templateConfigs', icon: '⎘' },
- { href: '/telegram-bots', key: 'nav.telegramBots', icon: '⊡' },
- { href: '/targets', key: 'nav.targets', icon: '◇' },
+ { href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
+ { href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
+ { href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
+ { href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
+ { href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
+ { href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
+ { href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
];
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
- // Re-derive translations when locale changes
- function tt(key: string): string {
- void localeVersion; // dependency on reactive counter
- return t(key);
- }
-
onMount(async () => {
initLocale();
initTheme();
- // Restore sidebar state
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
}
@@ -73,9 +67,6 @@
function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en');
- localeVersion++;
- // Force full page re-render so child components re-evaluate t() calls
- window.location.reload();
}
function toggleSidebar() {
@@ -90,22 +81,22 @@
{@render children()}
{:else if auth.loading}
-
{tt('common.loading')}
+
{t('common.loading')}
{:else if auth.user}
{/if}
@@ -186,33 +177,55 @@
+
+
+
-
-
+{:else}
+
+
+
{t('common.loading')}
+
{/if}
-
- { showPasswordForm = false; pwdMsg = ''; }}>
+
+ { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 332cef4..24b85b5 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -5,35 +5,79 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
+ import MdiIcon from '$lib/components/MdiIcon.svelte';
let status = $state(null);
let loaded = $state(false);
- onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } });
+ let error = $state('');
+
+ onMount(async () => {
+ try {
+ status = await api('/status');
+ } catch (err: any) {
+ error = err.message || t('common.error');
+ } finally {
+ loaded = true;
+ }
+ });
{#if !loaded}
+{:else if error}
+
+
+
{:else if status}
-
- {t('dashboard.servers')}
- {status.servers}
+
+
+
+
+
+
+
{t('dashboard.servers')}
+
{status.servers}
+
+
-
- {t('dashboard.activeTrackers')}
- {status.trackers.active} / {status.trackers.total}
+
+
+
+
+
+
+
{t('dashboard.activeTrackers')}
+
{status.trackers.active} / {status.trackers.total}
+
+
-
- {t('dashboard.targets')}
- {status.targets}
+
+
+
+
+
+
+
{t('dashboard.targets')}
+
{status.targets}
+
+
{t('dashboard.recentEvents')}
{#if status.recent_events.length === 0}
- {t('dashboard.noEvents')}
+
+
+
+
{t('dashboard.noEvents')}
+
+
{:else}
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index e739635..7eeed97 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -39,7 +39,7 @@
-