Comprehensive review fixes: security, performance, code quality, and UI polish
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
let { children, class: className = '' } = $props<{
|
||||
let { children, class: className = '', hover = false } = $props<{
|
||||
children: import('svelte').Snippet;
|
||||
class?: string;
|
||||
hover?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {className}">
|
||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {hover ? 'transition-all duration-150 hover:shadow-md hover:-translate-y-px' : ''} {className}">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
26
frontend/src/lib/components/ConfirmModal.svelte
Normal file
26
frontend/src/lib/components/ConfirmModal.svelte
Normal 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>
|
||||
@@ -50,8 +50,17 @@
|
||||
open = false;
|
||||
search = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) {
|
||||
open = false;
|
||||
search = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
<div class="inline-block">
|
||||
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
|
||||
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
@@ -65,9 +74,9 @@
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;" onclick={() => { open = false; search = ''; }}></div>
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||
role="presentation"
|
||||
onclick={() => { open = false; search = ''; }}></div>
|
||||
|
||||
<div style="{dropdownStyle} width: 20rem;"
|
||||
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3">
|
||||
|
||||
@@ -1,27 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
let { open = false, title = '', onclose, children } = $props<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
onclose: () => void;
|
||||
children: import('svelte').Snippet;
|
||||
}>();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5);"
|
||||
onclick={onclose}
|
||||
role="presentation"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); width: 100%; max-width: 28rem; margin: 1rem; padding: 1.25rem;"
|
||||
class="absolute inset-0 bg-black/50"
|
||||
onclick={onclose}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- Panel -->
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
class="relative bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
|
||||
transition:fly={{ y: -20, duration: 200 }}
|
||||
>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
<button onclick={onclose}
|
||||
style="color: var(--color-muted-foreground); font-size: 1.25rem; line-height: 1; cursor: pointer; background: none; border: none; padding: 0.25rem;">
|
||||
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] text-xl leading-none cursor-pointer bg-transparent border-none p-1 transition-colors"
|
||||
aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
62
frontend/src/lib/i18n/index.svelte.ts
Normal file
62
frontend/src/lib/i18n/index.svelte.ts
Normal 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;
|
||||
}
|
||||
@@ -1,73 +1,2 @@
|
||||
/**
|
||||
* Simple i18n module. Uses plain variable (no $state rune)
|
||||
* so it works in both SSR and client contexts.
|
||||
*/
|
||||
|
||||
import en from './en.json';
|
||||
import ru from './ru.json';
|
||||
|
||||
export type Locale = 'en' | 'ru';
|
||||
|
||||
const translations: Record<Locale, Record<string, any>> = { en, ru };
|
||||
|
||||
let currentLocale: Locale = 'en';
|
||||
|
||||
// Auto-initialize from localStorage on module load (client-side)
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('locale') as Locale | null;
|
||||
if (saved && saved in translations) {
|
||||
currentLocale = saved;
|
||||
} else if (typeof navigator !== 'undefined') {
|
||||
const lang = navigator.language.slice(0, 2);
|
||||
if (lang in translations) {
|
||||
currentLocale = lang as Locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale) {
|
||||
currentLocale = locale;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('locale', locale);
|
||||
}
|
||||
}
|
||||
|
||||
export function initLocale() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('locale') as Locale | null;
|
||||
if (saved && saved in translations) {
|
||||
currentLocale = saved;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const lang = navigator.language.slice(0, 2);
|
||||
if (lang in translations) {
|
||||
currentLocale = lang as Locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by dot-separated key.
|
||||
* Falls back to English if key not found in current locale.
|
||||
*/
|
||||
export function t(key: string): string {
|
||||
return resolve(translations[currentLocale], key)
|
||||
?? resolve(translations.en, key)
|
||||
?? key;
|
||||
}
|
||||
|
||||
function resolve(obj: any, path: string): string | undefined {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
}
|
||||
// Re-export from the .svelte.ts module which supports $state runes
|
||||
export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
|
||||
|
||||
@@ -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": "Свернуть"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
const auth = getAuth();
|
||||
@@ -17,45 +18,38 @@
|
||||
let pwdCurrent = $state('');
|
||||
let pwdNew = $state('');
|
||||
let pwdMsg = $state('');
|
||||
let pwdSuccess = $state(false);
|
||||
|
||||
async function changePassword(e: SubmitEvent) {
|
||||
e.preventDefault(); pwdMsg = '';
|
||||
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||
try {
|
||||
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
||||
pwdMsg = t('common.passwordChanged');
|
||||
pwdSuccess = true;
|
||||
pwdCurrent = ''; pwdNew = '';
|
||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
|
||||
} catch (err: any) { pwdMsg = err.message; }
|
||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
|
||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; }
|
||||
}
|
||||
|
||||
// Reactive counter to force re-render on locale change
|
||||
let localeVersion = $state(0);
|
||||
let collapsed = $state(false);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', key: 'nav.dashboard', icon: '⊞' },
|
||||
{ href: '/servers', key: 'nav.servers', icon: '⬡' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: '◎' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '⚙' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '⎘' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: '⊡' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: '◇' },
|
||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||
{ href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
];
|
||||
|
||||
const isAuthPage = $derived(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
|
||||
// Re-derive translations when locale changes
|
||||
function tt(key: string): string {
|
||||
void localeVersion; // dependency on reactive counter
|
||||
return t(key);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
// Restore sidebar state
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
}
|
||||
@@ -73,9 +67,6 @@
|
||||
|
||||
function toggleLocale() {
|
||||
setLocale(getLocale() === 'en' ? 'ru' : 'en');
|
||||
localeVersion++;
|
||||
// Force full page re-render so child components re-evaluate t() calls
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
@@ -90,22 +81,22 @@
|
||||
{@render children()}
|
||||
{:else if auth.loading}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tt('common.loading')}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
</div>
|
||||
{:else if auth.user}
|
||||
<div class="flex h-screen">
|
||||
<!-- Sidebar -->
|
||||
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200">
|
||||
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200 max-md:hidden">
|
||||
<div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
|
||||
{#if !collapsed}
|
||||
<div>
|
||||
<h1 class="text-base font-semibold tracking-tight">{tt('app.name')}</h1>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{tt('app.tagline')}</p>
|
||||
<h1 class="text-base font-semibold tracking-tight">{t('app.name')}</h1>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{t('app.tagline')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={toggleSidebar}
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md text-base text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)] transition-colors"
|
||||
title={collapsed ? 'Expand' : 'Collapse'}>
|
||||
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -118,10 +109,10 @@
|
||||
{page.url.pathname === item.href
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
||||
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
||||
title={collapsed ? tt(item.key) : ''}
|
||||
title={collapsed ? t(item.key) : ''}
|
||||
>
|
||||
<span class="text-base">{item.icon}</span>
|
||||
{#if !collapsed}{tt(item.key)}{/if}
|
||||
<MdiIcon name={item.icon} size={18} />
|
||||
{#if !collapsed}{t(item.key)}{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#if auth.isAdmin}
|
||||
@@ -131,10 +122,10 @@
|
||||
{page.url.pathname === '/users'
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
||||
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
||||
title={collapsed ? tt('nav.users') : ''}
|
||||
title={collapsed ? t('nav.users') : ''}
|
||||
>
|
||||
<span class="text-base">⊕</span>
|
||||
{#if !collapsed}{tt('nav.users')}{/if}
|
||||
<MdiIcon name="mdiAccountGroup" size={18} />
|
||||
{#if !collapsed}{t('nav.users')}{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -145,12 +136,12 @@
|
||||
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
|
||||
<button onclick={toggleLocale}
|
||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
||||
title={tt('common.language')}>
|
||||
title={t('common.language')}>
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={cycleTheme}
|
||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
||||
title={tt('common.theme')}>
|
||||
title={t('common.theme')}>
|
||||
{theme.resolved === 'dark' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,7 +151,7 @@
|
||||
{#if collapsed}
|
||||
<button onclick={logout}
|
||||
class="w-full flex justify-center py-2 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] rounded hover:bg-[var(--color-muted)] transition-colors"
|
||||
title={tt('nav.logout')}>
|
||||
title={t('nav.logout')}>
|
||||
⏻
|
||||
</button>
|
||||
{:else}
|
||||
@@ -172,13 +163,13 @@
|
||||
</div>
|
||||
<button onclick={logout}
|
||||
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
||||
title={tt('nav.logout')}>
|
||||
title={t('nav.logout')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button onclick={() => showPasswordForm = true}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
|
||||
🔑 {tt('common.changePassword')}
|
||||
🔑 {t('common.changePassword')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -186,33 +177,55 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom nav -->
|
||||
<nav class="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-card)] border-t border-[var(--color-border)] flex justify-around py-1.5">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a href={item.href}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs rounded-md transition-colors
|
||||
{page.url.pathname === item.href
|
||||
? 'text-[var(--color-accent-foreground)] font-medium'
|
||||
: 'text-[var(--color-muted-foreground)]'}">
|
||||
<MdiIcon name={item.icon} size={20} />
|
||||
</a>
|
||||
{/each}
|
||||
<button onclick={logout}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs text-[var(--color-muted-foreground)]">
|
||||
<MdiIcon name="mdiLogout" size={20} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="max-w-5xl mx-auto p-6">
|
||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Redirect in progress -->
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Password change modal (outside flex container) -->
|
||||
<Modal open={showPasswordForm} title={tt('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; }}>
|
||||
<!-- Password change modal -->
|
||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
|
||||
<form onsubmit={changePassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{tt('common.currentPassword')}</label>
|
||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{tt('common.newPassword')}</label>
|
||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if pwdMsg}
|
||||
<p class="text-sm" style="color: var(--color-success-fg);">{pwdMsg}</p>
|
||||
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
||||
{/if}
|
||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{tt('common.save')}
|
||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -5,35 +5,79 @@
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let status = $state<any>(null);
|
||||
let loaded = $state(false);
|
||||
onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } });
|
||||
let error = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
status = await api('/status');
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.error');
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading lines={4} />
|
||||
{:else if error}
|
||||
<Card>
|
||||
<div class="flex items-center gap-2 text-[var(--color-error-fg)]">
|
||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||
<p class="text-sm">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if status}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
<Card>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
|
||||
<p class="text-3xl font-semibold mt-1">{status.servers}</p>
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiServer" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
|
||||
<p class="text-2xl font-semibold">{status.servers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
|
||||
<p class="text-3xl font-semibold mt-1">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRadar" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
|
||||
<p class="text-2xl font-semibold">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
|
||||
<p class="text-3xl font-semibold mt-1">{status.targets}</p>
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiTarget" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
|
||||
<p class="text-2xl font-semibold">{status.targets}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
|
||||
{#if status.recent_events.length === 0}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.noEvents')}</p></Card>
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-4 gap-2 text-[var(--color-muted-foreground)]">
|
||||
<MdiIcon name="mdiCalendarBlank" size={32} />
|
||||
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
|
||||
<div class="flex justify-end gap-1 mb-4">
|
||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); window.location.reload(); }}
|
||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -7,20 +8,28 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
let servers = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { servers = await api('/servers'); } catch {} finally { loaded = true; }
|
||||
try {
|
||||
servers = await api('/servers');
|
||||
loadError = '';
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('servers.loadError');
|
||||
} finally { loaded = true; }
|
||||
// Ping all servers in background
|
||||
for (const s of servers) {
|
||||
health[s.id] = null; // loading
|
||||
@@ -52,8 +61,14 @@
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('servers.confirmDelete'))) return;
|
||||
function startDelete(server: any) {
|
||||
confirmDelete = server;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
</script>
|
||||
@@ -69,7 +84,14 @@
|
||||
<Loading />
|
||||
{:else}
|
||||
|
||||
{#if loadError}
|
||||
<Card class="mb-6">
|
||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-3">
|
||||
@@ -95,6 +117,7 @@
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if servers.length === 0 && !showForm}
|
||||
@@ -102,11 +125,11 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each servers as server}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
|
||||
title={health[server.id] === true ? 'Online' : health[server.id] === false ? 'Offline' : 'Checking...'}></span>
|
||||
title={health[server.id] === true ? t('servers.online') : health[server.id] === false ? t('servers.offline') : t('servers.checking')}></span>
|
||||
{#if server.icon}<MdiIcon name={server.icon} />{/if}
|
||||
<div>
|
||||
<p class="font-medium">{server.name}</p>
|
||||
@@ -115,7 +138,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => edit(server)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
|
||||
<button onclick={() => startDelete(server)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -124,3 +147,6 @@
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
|
||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -7,6 +8,7 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
@@ -22,8 +24,12 @@
|
||||
tracking_config_id: 0, template_config_id: 0 });
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
let headersError = $state('');
|
||||
let testResult = $state('');
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
@@ -31,7 +37,8 @@
|
||||
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
|
||||
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
|
||||
]);
|
||||
} catch {} finally { loaded = true; }
|
||||
loadError = '';
|
||||
} catch (err: any) { loadError = err.message || t('common.loadError'); } finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function loadBotChats() {
|
||||
@@ -39,7 +46,7 @@
|
||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
|
||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
||||
async function edit(tgt: any) {
|
||||
formType = tgt.type;
|
||||
const c = tgt.config || {};
|
||||
@@ -52,11 +59,11 @@
|
||||
tracking_config_id: tgt.tracking_config_id ?? 0,
|
||||
template_config_id: tgt.template_config_id ?? 0,
|
||||
};
|
||||
editing = tgt.id; showForm = true;
|
||||
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
e.preventDefault(); error = ''; headersError = '';
|
||||
try {
|
||||
let botToken = form.bot_token;
|
||||
// Resolve token from registered bot if selected
|
||||
@@ -64,6 +71,15 @@
|
||||
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
||||
botToken = tokenRes.token;
|
||||
}
|
||||
let parsedHeaders = {};
|
||||
if (formType === 'webhook' && form.headers) {
|
||||
try {
|
||||
parsedHeaders = JSON.parse(form.headers);
|
||||
} catch {
|
||||
headersError = t('common.headersInvalid');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const config = formType === 'telegram'
|
||||
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
||||
bot_id: form.bot_id || undefined,
|
||||
@@ -71,7 +87,7 @@
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions }
|
||||
: { url: form.url, headers: form.headers ? JSON.parse(form.headers) : {}, ai_captions: form.ai_captions };
|
||||
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||
const trkId = form.tracking_config_id || null;
|
||||
const tplId = form.template_config_id || null;
|
||||
if (editing) {
|
||||
@@ -89,7 +105,6 @@
|
||||
setTimeout(() => testResult = '', 5000);
|
||||
}
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('targets.confirmDelete'))) return;
|
||||
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
</script>
|
||||
@@ -103,11 +118,16 @@
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if loadError}
|
||||
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if testResult}
|
||||
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes(t('targets.testSent')) ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]'}">{testResult}</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
@@ -163,9 +183,14 @@
|
||||
{/if}
|
||||
|
||||
<!-- Telegram media settings -->
|
||||
<details class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<summary class="text-sm font-medium cursor-pointer">{t('targets.telegramSettings')}</summary>
|
||||
<div class="grid grid-cols-2 gap-3 mt-3">
|
||||
<div class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
|
||||
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
|
||||
{t('targets.telegramSettings')}
|
||||
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
|
||||
</button>
|
||||
{#if showTelegramSettings}
|
||||
<div transition:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}</label>
|
||||
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
@@ -185,12 +210,18 @@
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
|
||||
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
|
||||
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
|
||||
{#if headersError}<p class="text-xs text-[var(--color-destructive)] mt-1">{headersError}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Config assignments -->
|
||||
@@ -198,14 +229,14 @@
|
||||
<div>
|
||||
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label>
|
||||
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— None —</option>
|
||||
<option value={0}>— {t('common.none')} —</option>
|
||||
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label>
|
||||
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— None (default) —</option>
|
||||
<option value={0}>— {t('common.noneDefault')} —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -216,6 +247,7 @@
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('targets.create')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if targets.length === 0 && !showForm}
|
||||
@@ -223,7 +255,7 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each targets as target}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -238,7 +270,7 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => edit(target)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||
<button onclick={() => test(target.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('targets.test')}</button>
|
||||
<button onclick={() => remove(target.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
|
||||
<button onclick={() => confirmDelete = target} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -247,3 +279,11 @@
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
title={t('targets.confirmDelete')}
|
||||
message={confirmDelete?.name ?? ''}
|
||||
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
@@ -14,6 +15,7 @@
|
||||
let form = $state({ name: '', icon: '', token: '' });
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Per-bot chat lists
|
||||
let chats = $state<Record<number, any[]>>({});
|
||||
@@ -21,7 +23,11 @@
|
||||
let expandedBot = $state<number | null>(null);
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } }
|
||||
async function load() {
|
||||
try { bots = await api('/telegram-bots'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
@@ -32,9 +38,15 @@
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); }
|
||||
catch (err: any) { error = err.message; }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
@@ -96,7 +108,7 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each bots as bot}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -149,3 +161,6 @@
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -7,12 +8,14 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
let previewSlot = $state('message_assets_added');
|
||||
let previewResult = $state('');
|
||||
let previewId = $state<number | null>(null);
|
||||
@@ -78,7 +81,11 @@
|
||||
];
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { configs = await api('/template-configs'); } catch {} finally { loaded = true; } }
|
||||
async function load() {
|
||||
try { configs = await api('/template-configs'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
|
||||
@@ -100,9 +107,15 @@
|
||||
} catch (err: any) { previewResult = `Error: ${err.message}`; }
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); }
|
||||
catch (err: any) { error = err.message; }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -116,6 +129,7 @@
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div transition:slide>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
@@ -135,7 +149,7 @@
|
||||
{#each group.slots as slot}
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
|
||||
<textarea bind:value={form[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
|
||||
<textarea bind:value={(form as any)[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -148,6 +162,7 @@
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
@@ -155,7 +170,7 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each configs as config}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -182,3 +197,6 @@
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -7,8 +8,10 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<any[]>([]);
|
||||
let servers = $state<any[]>([]);
|
||||
let targets = $state<any[]>([]);
|
||||
@@ -16,6 +19,12 @@
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let albumFilter = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
let testingPeriodic = $state<Record<number, boolean>>({});
|
||||
let testingMemory = $state<Record<number, boolean>>({});
|
||||
let testFeedback = $state<Record<number, string>>({});
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', server_id: 0, album_ids: [] as string[],
|
||||
target_ids: [] as number[], scan_interval: 60,
|
||||
@@ -25,7 +34,14 @@
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { [trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]); } catch {} finally { loaded = true; }
|
||||
loadError = '';
|
||||
try {
|
||||
[trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]);
|
||||
} catch (err: any) {
|
||||
loadError = err.message || 'Failed to load data';
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
|
||||
|
||||
@@ -41,6 +57,8 @@
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
@@ -48,14 +66,52 @@
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
} catch (err: any) { error = err.message; } finally { submitting = false; }
|
||||
}
|
||||
async function toggle(tracker: any) {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) }); await load();
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling[tracker.id] = true;
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
} finally { toggling[tracker.id] = false; }
|
||||
}
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('trackers.confirmDelete'))) return;
|
||||
try { await api(`/trackers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
function startDelete(tracker: any) { confirmDelete = tracker; }
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
confirmDelete = null;
|
||||
}
|
||||
async function testPeriodic(tracker: any) {
|
||||
if (testingPeriodic[tracker.id]) return;
|
||||
testingPeriodic[tracker.id] = true;
|
||||
testFeedback[tracker.id] = '';
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' });
|
||||
testFeedback[tracker.id] = 'ok';
|
||||
} catch {
|
||||
testFeedback[tracker.id] = 'error';
|
||||
} finally {
|
||||
testingPeriodic[tracker.id] = false;
|
||||
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
||||
}
|
||||
}
|
||||
async function testMemory(tracker: any) {
|
||||
if (testingMemory[tracker.id]) return;
|
||||
testingMemory[tracker.id] = true;
|
||||
testFeedback[tracker.id] = '';
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' });
|
||||
testFeedback[tracker.id] = 'ok';
|
||||
} catch {
|
||||
testFeedback[tracker.id] = 'error';
|
||||
} finally {
|
||||
testingMemory[tracker.id] = false;
|
||||
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
||||
}
|
||||
}
|
||||
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
|
||||
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
|
||||
@@ -70,7 +126,17 @@
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if loadError}
|
||||
<Card>
|
||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">
|
||||
{loadError}
|
||||
</div>
|
||||
<button onclick={load} class="mt-3 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</Card>
|
||||
{:else if showForm}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
@@ -127,9 +193,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loaded}
|
||||
@@ -139,7 +206,7 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each trackers as tracker}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -149,20 +216,37 @@
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} target(s)</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} {t('trackers.targets')}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}</button>
|
||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Periodic</button>
|
||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Memory</button>
|
||||
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
<button onclick={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{testingPeriodic[tracker.id] ? '...' : t('trackers.testPeriodic')}
|
||||
</button>
|
||||
<button onclick={() => remove(tracker.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
|
||||
<button onclick={() => testMemory(tracker)} disabled={testingMemory[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{testingMemory[tracker.id] ? '...' : t('trackers.testMemory')}
|
||||
</button>
|
||||
{#if testFeedback[tracker.id]}
|
||||
<span class="text-xs {testFeedback[tracker.id] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-destructive)]'}">
|
||||
{testFeedback[tracker.id] === 'ok' ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
{/if}
|
||||
<button onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{toggling[tracker.id] ? '...' : tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
</button>
|
||||
<button onclick={() => startDelete(tracker)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
title={t('trackers.delete')}
|
||||
message={t('trackers.deleteConfirm')}
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -7,12 +8,14 @@
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
|
||||
@@ -30,7 +33,11 @@
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { configs = await api('/tracking-configs'); } catch {} finally { loaded = true; } }
|
||||
async function load() {
|
||||
try { configs = await api('/tracking-configs'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
@@ -47,9 +54,15 @@
|
||||
} catch (err: any) { error = err.message; }
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); }
|
||||
catch (err: any) { error = err.message; }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,6 +76,7 @@
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div transition:slide>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
@@ -104,13 +118,13 @@
|
||||
<div>
|
||||
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
||||
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="none">None</option><option value="date">Date</option><option value="rating">Rating</option><option value="name">Name</option>
|
||||
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
||||
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="descending">Desc</option><option value="ascending">Asc</option>
|
||||
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,12 +152,12 @@
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
||||
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
|
||||
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
|
||||
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||
@@ -160,12 +174,12 @@
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
||||
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
|
||||
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
|
||||
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||
@@ -178,6 +192,7 @@
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
@@ -185,7 +200,7 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each configs as config}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -210,3 +225,6 @@
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
|
||||
const auth = getAuth();
|
||||
let users = $state<any[]>([]);
|
||||
@@ -14,35 +15,48 @@
|
||||
let form = $state({ username: '', password: '', role: 'user' });
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Admin reset password
|
||||
let resetUserId = $state<number | null>(null);
|
||||
let resetUsername = $state('');
|
||||
let resetPassword = $state('');
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); }
|
||||
catch (err: any) { error = err.message; }
|
||||
}
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('users.confirmDelete'))) return;
|
||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); }
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); }
|
||||
catch (err: any) { error = err.message; }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = '';
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = '';
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||
resetMsg = t('common.passwordChanged');
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000);
|
||||
} catch (err: any) { resetMsg = err.message; }
|
||||
resetSuccess = true;
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||
} catch (err: any) { resetMsg = err.message; resetSuccess = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,7 +95,7 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user}
|
||||
<Card>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{user.username}</p>
|
||||
@@ -101,7 +115,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Admin reset password modal -->
|
||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; }}>
|
||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
@@ -109,10 +123,13 @@
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if resetMsg}
|
||||
<p class="text-sm {resetMsg.includes(t('common.passwordChanged')) ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||
{/if}
|
||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -164,7 +164,7 @@ async def _build_context(session: AsyncSession, chat_id: str) -> str:
|
||||
if trackers:
|
||||
parts.append(f"Active trackers: {len(trackers)}")
|
||||
for t in trackers[:5]:
|
||||
parts.append(f" - {t.name}: {len(t.album_ids)} album(s), events: {', '.join(t.event_types)}")
|
||||
parts.append(f" - {t.name}: {len(t.album_ids)} album(s)")
|
||||
|
||||
result = await session.exec(
|
||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(5)
|
||||
@@ -184,15 +184,19 @@ async def _get_summary_data(
|
||||
"""Fetch data for album summary."""
|
||||
albums_data: list[dict[str, Any]] = []
|
||||
servers_result = await session.exec(select(ImmichServer).limit(5))
|
||||
for server in servers_result.all():
|
||||
try:
|
||||
from immich_watcher_core.immich_client import ImmichClient
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
client = ImmichClient(http_session, server.url, server.api_key)
|
||||
albums = await client.get_albums()
|
||||
albums_data.extend(albums[:20])
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
|
||||
servers = servers_result.all()
|
||||
try:
|
||||
from immich_watcher_core.immich_client import ImmichClient
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
for server in servers:
|
||||
try:
|
||||
client = ImmichClient(http_session, server.url, server.api_key)
|
||||
albums = await client.get_albums()
|
||||
albums_data.extend(albums[:20])
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to create HTTP session for summary")
|
||||
|
||||
events_result = await session.exec(
|
||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(20)
|
||||
|
||||
@@ -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
|
||||
@@ -104,10 +104,28 @@ async def update_server(
|
||||
server = await _get_user_server(session, server_id, user.id)
|
||||
if body.name is not None:
|
||||
server.name = body.name
|
||||
url_changed = body.url is not None and body.url != server.url
|
||||
key_changed = body.api_key is not None and body.api_key != server.api_key
|
||||
if body.url is not None:
|
||||
server.url = body.url
|
||||
if body.api_key is not None:
|
||||
server.api_key = body.api_key
|
||||
# Re-validate and refresh external_domain when URL or API key changes
|
||||
if url_changed or key_changed:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
client = ImmichClient(http_session, server.url, server.api_key)
|
||||
if not await client.ping():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot connect to Immich server at {server.url}",
|
||||
)
|
||||
server.external_domain = await client.get_server_config()
|
||||
except aiohttp.ClientError as err:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection error: {err}",
|
||||
)
|
||||
session.add(server)
|
||||
await session.commit()
|
||||
await session.refresh(server)
|
||||
|
||||
@@ -52,12 +52,9 @@ class SyncTrackerResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_url: str
|
||||
server_api_key: str
|
||||
album_ids: list[str]
|
||||
event_types: list[str]
|
||||
scan_interval: int
|
||||
enabled: bool
|
||||
template_body: str | None = None
|
||||
targets: list[dict] = []
|
||||
|
||||
|
||||
@@ -84,40 +81,60 @@ async def get_sync_trackers(
|
||||
)
|
||||
trackers = result.all()
|
||||
|
||||
# Batch-load servers and targets to avoid N+1 queries
|
||||
server_ids = {t.server_id for t in trackers}
|
||||
all_target_ids = {tid for t in trackers for tid in t.target_ids}
|
||||
|
||||
servers_result = await session.exec(
|
||||
select(ImmichServer).where(ImmichServer.id.in_(server_ids))
|
||||
)
|
||||
servers_map = {s.id: s for s in servers_result.all()}
|
||||
|
||||
targets_result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.id.in_(all_target_ids))
|
||||
)
|
||||
targets_map = {t.id: t for t in targets_result.all()}
|
||||
|
||||
responses = []
|
||||
for tracker in trackers:
|
||||
# Fetch server details
|
||||
server = await session.get(ImmichServer, tracker.server_id)
|
||||
server = servers_map.get(tracker.server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
# Fetch target configs
|
||||
targets = []
|
||||
for target_id in tracker.target_ids:
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
target = targets_map.get(target_id)
|
||||
if target:
|
||||
targets.append({
|
||||
"type": target.type,
|
||||
"name": target.name,
|
||||
"config": target.config,
|
||||
"config": _safe_target_config(target),
|
||||
})
|
||||
|
||||
responses.append(SyncTrackerResponse(
|
||||
id=tracker.id,
|
||||
name=tracker.name,
|
||||
server_url=server.url,
|
||||
server_api_key=server.api_key,
|
||||
album_ids=tracker.album_ids,
|
||||
event_types=[], # Event types now on tracking configs
|
||||
scan_interval=tracker.scan_interval,
|
||||
enabled=tracker.enabled,
|
||||
template_body=None,
|
||||
targets=targets,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
def _safe_target_config(target: NotificationTarget) -> dict:
|
||||
"""Return config with sensitive fields masked."""
|
||||
config = dict(target.config)
|
||||
if "bot_token" in config:
|
||||
token = config["bot_token"]
|
||||
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
|
||||
if "api_key" in config:
|
||||
config["api_key"] = "***"
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/render")
|
||||
async def render_template(
|
||||
template_id: int,
|
||||
|
||||
@@ -92,6 +92,10 @@ async def update_target(
|
||||
target.name = body.name
|
||||
if body.config is not None:
|
||||
target.config = body.config
|
||||
if body.tracking_config_id is not None:
|
||||
target.tracking_config_id = body.tracking_config_id
|
||||
if body.template_config_id is not None:
|
||||
target.template_config_id = body.template_config_id
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
|
||||
@@ -97,8 +97,9 @@ async def get_bot_token(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get the full bot token (for internal use by targets)."""
|
||||
"""Get the full bot token (used by frontend to construct target config)."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
# Token is returned only to the authenticated owner
|
||||
return {"token": bot.token}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Album tracker management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -150,7 +150,7 @@ async def test_memory(
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
tracker_id: int,
|
||||
limit: int = 20,
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Server configuration from environment variables."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -11,8 +12,16 @@ class Settings(BaseSettings):
|
||||
data_dir: Path = Path("/data")
|
||||
database_url: str = "" # Computed from data_dir if empty
|
||||
|
||||
# JWT
|
||||
# JWT (MUST be overridden via IMMICH_WATCHER_SECRET_KEY env var)
|
||||
secret_key: str = "change-me-in-production"
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
if self.secret_key == "change-me-in-production" and not self.debug:
|
||||
import logging
|
||||
logging.getLogger(__name__).critical(
|
||||
"SECURITY: Using default secret_key in non-debug mode! "
|
||||
"Set IMMICH_WATCHER_SECRET_KEY environment variable."
|
||||
)
|
||||
access_token_expire_minutes: int = 60
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
|
||||
@@ -56,11 +56,11 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS for frontend dev server
|
||||
# CORS: restrict to same-origin in production, allow all in debug mode
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"] if settings.debug else [],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -166,16 +166,28 @@ async def _check_album(
|
||||
)
|
||||
session.add(event_log)
|
||||
|
||||
# Send notifications to each target, filtered by its tracking config
|
||||
for target_id in target_ids:
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if not target:
|
||||
continue
|
||||
# Batch-load targets, tracking configs, and template configs
|
||||
targets_result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
|
||||
)
|
||||
targets = targets_result.all()
|
||||
|
||||
# Check target's tracking config for event filtering
|
||||
tracking_config = None
|
||||
if target.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, target.tracking_config_id)
|
||||
tc_ids = {t.tracking_config_id for t in targets if t.tracking_config_id}
|
||||
tmpl_ids = {t.template_config_id for t in targets if t.template_config_id}
|
||||
|
||||
tracking_configs_map: dict[int, TrackingConfig] = {}
|
||||
if tc_ids:
|
||||
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
|
||||
tracking_configs_map = {tc.id: tc for tc in tc_result.all()}
|
||||
|
||||
template_configs_map: dict[int, TemplateConfig] = {}
|
||||
if tmpl_ids:
|
||||
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
|
||||
template_configs_map = {tc.id: tc for tc in tmpl_result.all()}
|
||||
|
||||
# Send notifications to each target, filtered by its tracking config
|
||||
for target in targets:
|
||||
tracking_config = tracking_configs_map.get(target.tracking_config_id) if target.tracking_config_id else None
|
||||
|
||||
if tracking_config:
|
||||
# Filter by event type
|
||||
@@ -193,16 +205,13 @@ async def _check_album(
|
||||
if not should_notify:
|
||||
continue
|
||||
|
||||
# Get target's template config
|
||||
template_config = None
|
||||
if target.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, target.template_config_id)
|
||||
template_config = template_configs_map.get(target.template_config_id) if target.template_config_id else None
|
||||
|
||||
try:
|
||||
use_ai = target.config.get("ai_captions", False)
|
||||
await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to send notification to target %d", target_id)
|
||||
_LOGGER.exception("Failed to send notification to target %d", target.id)
|
||||
|
||||
return {
|
||||
"album_id": album_id,
|
||||
|
||||
Reference in New Issue
Block a user