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()
|
items = queue.get_all()
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
sent_indices = []
|
||||||
for i in indices:
|
for i in indices:
|
||||||
if i >= len(items):
|
if i >= len(items):
|
||||||
continue
|
continue
|
||||||
@@ -371,14 +372,16 @@ async def _send_queued_items(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
sent_indices.append(i)
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Failed to send queued notification %d", i + 1)
|
_LOGGER.exception("Failed to send queued notification %d", i + 1)
|
||||||
|
|
||||||
# Small delay between notifications to avoid rate limiting
|
# Small delay between notifications to avoid rate limiting
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# Remove sent items from queue (in reverse order to preserve indices)
|
# Only remove successfully sent items (in reverse order to preserve indices)
|
||||||
await queue.async_remove_indices(sorted(indices, reverse=True))
|
if sent_indices:
|
||||||
|
await queue.async_remove_indices(sorted(sent_indices, reverse=True))
|
||||||
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
|
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
|
||||||
|
|
||||||
|
|
||||||
@@ -419,7 +422,7 @@ async def _async_update_listener(
|
|||||||
subentries_data = entry_data["subentries"]
|
subentries_data = entry_data["subentries"]
|
||||||
for subentry_data in subentries_data.values():
|
for subentry_data in subentries_data.values():
|
||||||
subentry_data.coordinator.update_scan_interval(new_interval)
|
subentry_data.coordinator.update_scan_interval(new_interval)
|
||||||
subentry_data.coordinator._sync_client = sync_client
|
subentry_data.coordinator.update_sync_client(sync_client)
|
||||||
|
|
||||||
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)
|
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@@ -74,7 +76,7 @@ class ImmichAlbumNewAssetsSensor(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
|
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -93,7 +95,7 @@ class ImmichAlbumNewAssetsSensor(
|
|||||||
|
|
||||||
# Check if we're still within the reset window
|
# Check if we're still within the reset window
|
||||||
if self._album_data.last_change_time:
|
if self._album_data.last_change_time:
|
||||||
elapsed = datetime.now() - self._album_data.last_change_time
|
elapsed = dt_util.now() - self._album_data.last_change_time
|
||||||
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
|
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
|
||||||
# Auto-reset the flag
|
# Auto-reset the flag
|
||||||
self.coordinator.clear_new_assets_flag()
|
self.coordinator.clear_new_assets_flag()
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class ImmichCreateShareLinkButton(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
|
self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -158,7 +158,7 @@ class ImmichDeleteShareLinkButton(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
|
self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -248,7 +248,7 @@ class ImmichCreateProtectedLinkButton(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
|
self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -335,7 +335,7 @@ class ImmichDeleteProtectedLinkButton(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
|
self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=60)
|
_THUMBNAIL_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -68,7 +68,7 @@ class ImmichAlbumThumbnailCamera(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
|
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
|
||||||
self._cached_image: bytes | None = None
|
self._cached_image: bytes | None = None
|
||||||
self._last_thumbnail_id: str | None = None
|
self._last_thumbnail_id: str | None = None
|
||||||
@@ -131,7 +131,7 @@ class ImmichAlbumThumbnailCamera(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.get(thumbnail_url, headers=headers) as response:
|
async with session.get(thumbnail_url, headers=headers, timeout=_THUMBNAIL_TIMEOUT) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
self._cached_image = await response.read()
|
self._cached_image = await response.read()
|
||||||
self._last_thumbnail_id = self._album_data.thumbnail_asset_id
|
self._last_thumbnail_id = self._album_data.thumbnail_asset_id
|
||||||
|
|||||||
@@ -39,13 +39,16 @@ from .const import (
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||||
|
|
||||||
|
|
||||||
async def validate_connection(
|
async def validate_connection(
|
||||||
session: aiohttp.ClientSession, url: str, api_key: str
|
session: aiohttp.ClientSession, url: str, api_key: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate the Immich connection and return server info."""
|
"""Validate the Immich connection and return server info."""
|
||||||
headers = {"x-api-key": api_key}
|
headers = {"x-api-key": api_key}
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{url.rstrip('/')}/api/server/ping", headers=headers
|
f"{url.rstrip('/')}/api/server/ping", headers=headers, timeout=_CONNECT_TIMEOUT
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 401:
|
if response.status == 401:
|
||||||
raise InvalidAuth
|
raise InvalidAuth
|
||||||
@@ -169,23 +172,7 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
url = config_entry.data[CONF_IMMICH_URL]
|
url = config_entry.data[CONF_IMMICH_URL]
|
||||||
api_key = config_entry.data[CONF_API_KEY]
|
api_key = config_entry.data[CONF_API_KEY]
|
||||||
|
|
||||||
# Fetch available albums
|
if user_input is not None and self._albums:
|
||||||
session = async_get_clientsession(self.hass)
|
|
||||||
try:
|
|
||||||
self._albums = await fetch_albums(session, url, api_key)
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Failed to fetch albums")
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=vol.Schema({}),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self._albums:
|
|
||||||
return self.async_abort(reason="no_albums")
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
album_id = user_input[CONF_ALBUM_ID]
|
album_id = user_input[CONF_ALBUM_ID]
|
||||||
|
|
||||||
# Check if album is already configured
|
# Check if album is already configured
|
||||||
@@ -208,6 +195,23 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fetch available albums (only when displaying the form)
|
||||||
|
if not self._albums:
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
try:
|
||||||
|
self._albums = await fetch_albums(session, url, api_key)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Failed to fetch albums")
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema({}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._albums:
|
||||||
|
return self.async_abort(reason="no_albums")
|
||||||
|
|
||||||
# Get already configured album IDs
|
# Get already configured album IDs
|
||||||
configured_albums = set()
|
configured_albums = set()
|
||||||
for subentry in config_entry.subentries.values():
|
for subentry in config_entry.subentries.values():
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .storage import ImmichAlbumStorage
|
from .storage import ImmichAlbumStorage
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
@@ -122,7 +121,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
|
|
||||||
# Caches managed by the client
|
# Caches managed by the client
|
||||||
self._users_cache: dict[str, str] = {}
|
self._users_cache: dict[str, str] = {}
|
||||||
|
self._users_cache_time: float = 0
|
||||||
self._shared_links: list[SharedLinkInfo] = []
|
self._shared_links: list[SharedLinkInfo] = []
|
||||||
|
self._shared_links_dirty = True
|
||||||
self._server_config_fetched = False
|
self._server_config_fetched = False
|
||||||
|
|
||||||
def _get_client(self) -> ImmichClient:
|
def _get_client(self) -> ImmichClient:
|
||||||
@@ -181,6 +182,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
"""Update the scan interval."""
|
"""Update the scan interval."""
|
||||||
self.update_interval = timedelta(seconds=scan_interval)
|
self.update_interval = timedelta(seconds=scan_interval)
|
||||||
|
|
||||||
|
def update_sync_client(self, sync_client: Any | None) -> None:
|
||||||
|
"""Update the server sync client."""
|
||||||
|
self._sync_client = sync_client
|
||||||
|
|
||||||
async def async_refresh_now(self) -> None:
|
async def async_refresh_now(self) -> None:
|
||||||
"""Force an immediate refresh."""
|
"""Force an immediate refresh."""
|
||||||
await self.async_request_refresh()
|
await self.async_request_refresh()
|
||||||
@@ -321,6 +326,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
result = await client.create_shared_link(self._album_id, password)
|
result = await client.create_shared_link(self._album_id, password)
|
||||||
if result:
|
if result:
|
||||||
self._shared_links = await client.get_shared_links(self._album_id)
|
self._shared_links = await client.get_shared_links(self._album_id)
|
||||||
|
self._shared_links_dirty = False
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def async_delete_shared_link(self, link_id: str) -> bool:
|
async def async_delete_shared_link(self, link_id: str) -> bool:
|
||||||
@@ -329,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
result = await client.delete_shared_link(link_id)
|
result = await client.delete_shared_link(link_id)
|
||||||
if result:
|
if result:
|
||||||
self._shared_links = await client.get_shared_links(self._album_id)
|
self._shared_links = await client.get_shared_links(self._album_id)
|
||||||
|
self._shared_links_dirty = False
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def async_set_shared_link_password(
|
async def async_set_shared_link_password(
|
||||||
@@ -339,6 +346,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
result = await client.set_shared_link_password(link_id, password)
|
result = await client.set_shared_link_password(link_id, password)
|
||||||
if result:
|
if result:
|
||||||
self._shared_links = await client.get_shared_links(self._album_id)
|
self._shared_links = await client.get_shared_links(self._album_id)
|
||||||
|
self._shared_links_dirty = False
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def clear_new_assets_flag(self) -> None:
|
def clear_new_assets_flag(self) -> None:
|
||||||
@@ -358,12 +366,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
await client.get_server_config()
|
await client.get_server_config()
|
||||||
self._server_config_fetched = True
|
self._server_config_fetched = True
|
||||||
|
|
||||||
# Fetch users to resolve owner names
|
# Fetch users to resolve owner names (refresh every hour)
|
||||||
if not self._users_cache:
|
import time
|
||||||
|
if not self._users_cache or (time.monotonic() - self._users_cache_time > 3600):
|
||||||
self._users_cache = await client.get_users()
|
self._users_cache = await client.get_users()
|
||||||
|
self._users_cache_time = time.monotonic()
|
||||||
|
|
||||||
# Fetch shared links (refresh each time as links can change)
|
# Fetch shared links only when needed (on first load or after mutations)
|
||||||
self._shared_links = await client.get_shared_links(self._album_id)
|
if self._shared_links_dirty:
|
||||||
|
self._shared_links = await client.get_shared_links(self._album_id)
|
||||||
|
self._shared_links_dirty = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
album = await client.get_album(self._album_id, self._users_cache)
|
album = await client.get_album(self._album_id, self._users_cache)
|
||||||
@@ -389,7 +401,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
)
|
)
|
||||||
if change:
|
if change:
|
||||||
album.has_new_assets = change.added_count > 0
|
album.has_new_assets = change.added_count > 0
|
||||||
album.last_change_time = datetime.now()
|
album.last_change_time = dt_util.now()
|
||||||
self._fire_events(change, album)
|
self._fire_events(change, album)
|
||||||
elif self._persisted_asset_ids is not None:
|
elif self._persisted_asset_ids is not None:
|
||||||
# First refresh after restart - compare with persisted state
|
# First refresh after restart - compare with persisted state
|
||||||
@@ -423,7 +435,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
removed_asset_ids=list(removed_ids),
|
removed_asset_ids=list(removed_ids),
|
||||||
)
|
)
|
||||||
album.has_new_assets = change.added_count > 0
|
album.has_new_assets = change.added_count > 0
|
||||||
album.last_change_time = datetime.now()
|
album.last_change_time = dt_util.now()
|
||||||
self._fire_events(change, album)
|
self._fire_events(change, album)
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Detected changes during downtime for album '%s': +%d -%d",
|
"Detected changes during downtime for album '%s': +%d -%d",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
|
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||||
"requirements": ["immich-watcher-core==0.1.0"],
|
"requirements": ["immich-watcher-core==0.1.0"],
|
||||||
"version": "2.8.0"
|
"version": "2.8.0"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
self._unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
self._unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _album_data(self) -> AlbumData | None:
|
def _album_data(self) -> AlbumData | None:
|
||||||
@@ -239,27 +239,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
)
|
)
|
||||||
return {"assets": assets}
|
return {"assets": assets}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_quiet_hours(start_str: str | None, end_str: str | None) -> bool:
|
|
||||||
"""Check if current time is within quiet hours."""
|
|
||||||
from datetime import time as dt_time
|
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
if not start_str or not end_str:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
now = dt_util.now().time()
|
|
||||||
start_time = dt_time.fromisoformat(start_str)
|
|
||||||
end_time = dt_time.fromisoformat(end_str)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if start_time <= end_time:
|
|
||||||
return start_time <= now < end_time
|
|
||||||
else:
|
|
||||||
return now >= start_time or now < end_time
|
|
||||||
|
|
||||||
async def async_send_telegram_notification(
|
async def async_send_telegram_notification(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -280,7 +259,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
) -> ServiceResponse:
|
) -> ServiceResponse:
|
||||||
"""Send notification to Telegram."""
|
"""Send notification to Telegram."""
|
||||||
# Check quiet hours — queue notification if active
|
# Check quiet hours — queue notification if active
|
||||||
if self._is_quiet_hours(quiet_hours_start, quiet_hours_end):
|
from . import _is_quiet_hours
|
||||||
|
if _is_quiet_hours(quiet_hours_start, quiet_hours_end):
|
||||||
from . import _register_queue_timers
|
from . import _register_queue_timers
|
||||||
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
|
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
|
||||||
await queue.async_enqueue({
|
await queue.async_enqueue({
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class ImmichAlbumProtectedPasswordText(
|
|||||||
self._album_id = subentry.data[CONF_ALBUM_ID]
|
self._album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
|
||||||
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
|
unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||||
self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit"
|
self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ input, select, textarea {
|
|||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global focus-visible styles for accessibility */
|
||||||
|
input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Override browser autofill styles in dark mode */
|
/* Override browser autofill styles in dark mode */
|
||||||
[data-theme="dark"] input:-webkit-autofill,
|
[data-theme="dark"] input:-webkit-autofill,
|
||||||
[data-theme="dark"] input:-webkit-autofill:hover,
|
[data-theme="dark"] input:-webkit-autofill:hover,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { children, class: className = '' } = $props<{
|
let { children, class: className = '', hover = false } = $props<{
|
||||||
children: import('svelte').Snippet;
|
children: import('svelte').Snippet;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
hover?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {className}">
|
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {hover ? 'transition-all duration-150 hover:shadow-md hover:-translate-y-px' : ''} {className}">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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;
|
open = false;
|
||||||
search = '';
|
search = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
open = false;
|
||||||
|
search = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||||
|
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
|
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
|
||||||
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
|
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
|
||||||
@@ -65,9 +74,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
role="presentation"
|
||||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;" onclick={() => { open = false; search = ''; }}></div>
|
onclick={() => { open = false; search = ''; }}></div>
|
||||||
|
|
||||||
<div style="{dropdownStyle} width: 20rem;"
|
<div style="{dropdownStyle} width: 20rem;"
|
||||||
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3">
|
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3">
|
||||||
|
|||||||
@@ -1,27 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
let { open = false, title = '', onclose, children } = $props<{
|
let { open = false, title = '', onclose, children } = $props<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
children: import('svelte').Snippet;
|
children: import('svelte').Snippet;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onclose();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5);"
|
role="presentation"
|
||||||
onclick={onclose}
|
class="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); width: 100%; max-width: 28rem; margin: 1rem; padding: 1.25rem;"
|
class="absolute inset-0 bg-black/50"
|
||||||
|
onclick={onclose}
|
||||||
|
role="presentation"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<!-- Panel -->
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
tabindex="-1"
|
||||||
|
class="relative bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}
|
||||||
|
transition:fly={{ y: -20, duration: 200 }}
|
||||||
>
|
>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
|
<h3 class="text-lg font-semibold">{title}</h3>
|
||||||
<button onclick={onclose}
|
<button onclick={onclose}
|
||||||
style="color: var(--color-muted-foreground); font-size: 1.25rem; line-height: 1; cursor: pointer; background: none; border: none; padding: 0.25rem;">
|
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] text-xl leading-none cursor-pointer bg-transparent border-none p-1 transition-colors"
|
||||||
|
aria-label="Close">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,11 @@
|
|||||||
"connecting": "Connecting...",
|
"connecting": "Connecting...",
|
||||||
"noServers": "No servers configured yet.",
|
"noServers": "No servers configured yet.",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"confirmDelete": "Delete this server?"
|
"confirmDelete": "Delete this server?",
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"loadError": "Failed to load servers."
|
||||||
},
|
},
|
||||||
"trackers": {
|
"trackers": {
|
||||||
"title": "Trackers",
|
"title": "Trackers",
|
||||||
@@ -177,7 +181,8 @@
|
|||||||
"private": "Private",
|
"private": "Private",
|
||||||
"group": "Group",
|
"group": "Group",
|
||||||
"supergroup": "Supergroup",
|
"supergroup": "Supergroup",
|
||||||
"channel": "Channel"
|
"channel": "Channel",
|
||||||
|
"confirmDelete": "Delete this bot?"
|
||||||
},
|
},
|
||||||
"trackingConfig": {
|
"trackingConfig": {
|
||||||
"title": "Tracking Configs",
|
"title": "Tracking Configs",
|
||||||
@@ -211,7 +216,20 @@
|
|||||||
"assetType": "Asset type",
|
"assetType": "Asset type",
|
||||||
"minRating": "Min rating",
|
"minRating": "Min rating",
|
||||||
"memoryMode": "Memory Mode (On This Day)",
|
"memoryMode": "Memory Mode (On This Day)",
|
||||||
"test": "Test"
|
"test": "Test",
|
||||||
|
"confirmDelete": "Delete this tracking config?",
|
||||||
|
"sortNone": "None",
|
||||||
|
"sortDate": "Date",
|
||||||
|
"sortRating": "Rating",
|
||||||
|
"sortName": "Name",
|
||||||
|
"orderDesc": "Descending",
|
||||||
|
"orderAsc": "Ascending",
|
||||||
|
"albumModePerAlbum": "Per album",
|
||||||
|
"albumModeCombined": "Combined",
|
||||||
|
"albumModeRandom": "Random",
|
||||||
|
"assetTypeAll": "All",
|
||||||
|
"assetTypePhoto": "Photo",
|
||||||
|
"assetTypeVideo": "Video"
|
||||||
},
|
},
|
||||||
"templateConfig": {
|
"templateConfig": {
|
||||||
"title": "Template Configs",
|
"title": "Template Configs",
|
||||||
@@ -246,7 +264,8 @@
|
|||||||
"memoryMode": "Memory mode",
|
"memoryMode": "Memory mode",
|
||||||
"telegramSettings": "Telegram",
|
"telegramSettings": "Telegram",
|
||||||
"videoWarning": "Video warning",
|
"videoWarning": "Video warning",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"confirmDelete": "Delete this template config?"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -258,6 +277,10 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
|
"none": "None",
|
||||||
|
"noneDefault": "None (default)",
|
||||||
|
"loadError": "Failed to load data",
|
||||||
|
"headersInvalid": "Invalid JSON",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
@@ -268,6 +291,8 @@
|
|||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
"currentPassword": "Current password",
|
"currentPassword": "Current password",
|
||||||
"newPassword": "New password",
|
"newPassword": "New password",
|
||||||
"passwordChanged": "Password changed successfully"
|
"passwordChanged": "Password changed successfully",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 @@
|
|||||||
/**
|
// Re-export from the .svelte.ts module which supports $state runes
|
||||||
* Simple i18n module. Uses plain variable (no $state rune)
|
export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
|
||||||
* so it works in both SSR and client contexts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import en from './en.json';
|
|
||||||
import ru from './ru.json';
|
|
||||||
|
|
||||||
export type Locale = 'en' | 'ru';
|
|
||||||
|
|
||||||
const translations: Record<Locale, Record<string, any>> = { en, ru };
|
|
||||||
|
|
||||||
let currentLocale: Locale = 'en';
|
|
||||||
|
|
||||||
// Auto-initialize from localStorage on module load (client-side)
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('locale') as Locale | null;
|
|
||||||
if (saved && saved in translations) {
|
|
||||||
currentLocale = saved;
|
|
||||||
} else if (typeof navigator !== 'undefined') {
|
|
||||||
const lang = navigator.language.slice(0, 2);
|
|
||||||
if (lang in translations) {
|
|
||||||
currentLocale = lang as Locale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocale(): Locale {
|
|
||||||
return currentLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLocale(locale: Locale) {
|
|
||||||
currentLocale = locale;
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
localStorage.setItem('locale', locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initLocale() {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('locale') as Locale | null;
|
|
||||||
if (saved && saved in translations) {
|
|
||||||
currentLocale = saved;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof navigator !== 'undefined') {
|
|
||||||
const lang = navigator.language.slice(0, 2);
|
|
||||||
if (lang in translations) {
|
|
||||||
currentLocale = lang as Locale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a translated string by dot-separated key.
|
|
||||||
* Falls back to English if key not found in current locale.
|
|
||||||
*/
|
|
||||||
export function t(key: string): string {
|
|
||||||
return resolve(translations[currentLocale], key)
|
|
||||||
?? resolve(translations.en, key)
|
|
||||||
?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolve(obj: any, path: string): string | undefined {
|
|
||||||
const parts = path.split('.');
|
|
||||||
let current = obj;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (current == null || typeof current !== 'object') return undefined;
|
|
||||||
current = current[part];
|
|
||||||
}
|
|
||||||
return typeof current === 'string' ? current : undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,7 +53,11 @@
|
|||||||
"connecting": "Подключение...",
|
"connecting": "Подключение...",
|
||||||
"noServers": "Серверы не настроены.",
|
"noServers": "Серверы не настроены.",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"confirmDelete": "Удалить этот сервер?"
|
"confirmDelete": "Удалить этот сервер?",
|
||||||
|
"online": "В сети",
|
||||||
|
"offline": "Не в сети",
|
||||||
|
"checking": "Проверка...",
|
||||||
|
"loadError": "Не удалось загрузить серверы."
|
||||||
},
|
},
|
||||||
"trackers": {
|
"trackers": {
|
||||||
"title": "Трекеры",
|
"title": "Трекеры",
|
||||||
@@ -177,7 +181,8 @@
|
|||||||
"private": "Личный",
|
"private": "Личный",
|
||||||
"group": "Группа",
|
"group": "Группа",
|
||||||
"supergroup": "Супергруппа",
|
"supergroup": "Супергруппа",
|
||||||
"channel": "Канал"
|
"channel": "Канал",
|
||||||
|
"confirmDelete": "Удалить этого бота?"
|
||||||
},
|
},
|
||||||
"trackingConfig": {
|
"trackingConfig": {
|
||||||
"title": "Конфигурации отслеживания",
|
"title": "Конфигурации отслеживания",
|
||||||
@@ -211,7 +216,20 @@
|
|||||||
"assetType": "Тип файлов",
|
"assetType": "Тип файлов",
|
||||||
"minRating": "Мин. рейтинг",
|
"minRating": "Мин. рейтинг",
|
||||||
"memoryMode": "Воспоминания (В этот день)",
|
"memoryMode": "Воспоминания (В этот день)",
|
||||||
"test": "Тест"
|
"test": "Тест",
|
||||||
|
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||||
|
"sortNone": "Нет",
|
||||||
|
"sortDate": "Дата",
|
||||||
|
"sortRating": "Рейтинг",
|
||||||
|
"sortName": "Имя",
|
||||||
|
"orderDesc": "По убыванию",
|
||||||
|
"orderAsc": "По возрастанию",
|
||||||
|
"albumModePerAlbum": "По альбомам",
|
||||||
|
"albumModeCombined": "Объединённый",
|
||||||
|
"albumModeRandom": "Случайный",
|
||||||
|
"assetTypeAll": "Все",
|
||||||
|
"assetTypePhoto": "Фото",
|
||||||
|
"assetTypeVideo": "Видео"
|
||||||
},
|
},
|
||||||
"templateConfig": {
|
"templateConfig": {
|
||||||
"title": "Конфигурации шаблонов",
|
"title": "Конфигурации шаблонов",
|
||||||
@@ -246,7 +264,8 @@
|
|||||||
"memoryMode": "Воспоминания",
|
"memoryMode": "Воспоминания",
|
||||||
"telegramSettings": "Telegram",
|
"telegramSettings": "Telegram",
|
||||||
"videoWarning": "Предупреждение о видео",
|
"videoWarning": "Предупреждение о видео",
|
||||||
"preview": "Предпросмотр"
|
"preview": "Предпросмотр",
|
||||||
|
"confirmDelete": "Удалить эту конфигурацию шаблона?"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -258,6 +277,10 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
"success": "Успешно",
|
"success": "Успешно",
|
||||||
|
"none": "Нет",
|
||||||
|
"noneDefault": "Нет (по умолчанию)",
|
||||||
|
"loadError": "Не удалось загрузить данные",
|
||||||
|
"headersInvalid": "Невалидный JSON",
|
||||||
"language": "Язык",
|
"language": "Язык",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
"light": "Светлая",
|
"light": "Светлая",
|
||||||
@@ -268,6 +291,8 @@
|
|||||||
"changePassword": "Сменить пароль",
|
"changePassword": "Сменить пароль",
|
||||||
"currentPassword": "Текущий пароль",
|
"currentPassword": "Текущий пароль",
|
||||||
"newPassword": "Новый пароль",
|
"newPassword": "Новый пароль",
|
||||||
"passwordChanged": "Пароль успешно изменён"
|
"passwordChanged": "Пароль успешно изменён",
|
||||||
|
"expand": "Развернуть",
|
||||||
|
"collapse": "Свернуть"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
@@ -17,45 +18,38 @@
|
|||||||
let pwdCurrent = $state('');
|
let pwdCurrent = $state('');
|
||||||
let pwdNew = $state('');
|
let pwdNew = $state('');
|
||||||
let pwdMsg = $state('');
|
let pwdMsg = $state('');
|
||||||
|
let pwdSuccess = $state(false);
|
||||||
|
|
||||||
async function changePassword(e: SubmitEvent) {
|
async function changePassword(e: SubmitEvent) {
|
||||||
e.preventDefault(); pwdMsg = '';
|
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||||
try {
|
try {
|
||||||
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
||||||
pwdMsg = t('common.passwordChanged');
|
pwdMsg = t('common.passwordChanged');
|
||||||
|
pwdSuccess = true;
|
||||||
pwdCurrent = ''; pwdNew = '';
|
pwdCurrent = ''; pwdNew = '';
|
||||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
|
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
|
||||||
} catch (err: any) { pwdMsg = err.message; }
|
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive counter to force re-render on locale change
|
|
||||||
let localeVersion = $state(0);
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', key: 'nav.dashboard', icon: '⊞' },
|
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||||
{ href: '/servers', key: 'nav.servers', icon: '⬡' },
|
{ href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
|
||||||
{ href: '/trackers', key: 'nav.trackers', icon: '◎' },
|
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '⚙' },
|
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '⎘' },
|
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: '⊡' },
|
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||||
{ href: '/targets', key: 'nav.targets', icon: '◇' },
|
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const isAuthPage = $derived(
|
const isAuthPage = $derived(
|
||||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-derive translations when locale changes
|
|
||||||
function tt(key: string): string {
|
|
||||||
void localeVersion; // dependency on reactive counter
|
|
||||||
return t(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
initLocale();
|
initLocale();
|
||||||
initTheme();
|
initTheme();
|
||||||
// Restore sidebar state
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
}
|
}
|
||||||
@@ -73,9 +67,6 @@
|
|||||||
|
|
||||||
function toggleLocale() {
|
function toggleLocale() {
|
||||||
setLocale(getLocale() === 'en' ? 'ru' : 'en');
|
setLocale(getLocale() === 'en' ? 'ru' : 'en');
|
||||||
localeVersion++;
|
|
||||||
// Force full page re-render so child components re-evaluate t() calls
|
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
@@ -90,22 +81,22 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if auth.loading}
|
{:else if auth.loading}
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tt('common.loading')}</p>
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if auth.user}
|
{:else if auth.user}
|
||||||
<div class="flex h-screen">
|
<div class="flex h-screen">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200">
|
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200 max-md:hidden">
|
||||||
<div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
|
<div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-base font-semibold tracking-tight">{tt('app.name')}</h1>
|
<h1 class="text-base font-semibold tracking-tight">{t('app.name')}</h1>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{tt('app.tagline')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{t('app.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={toggleSidebar}
|
<button onclick={toggleSidebar}
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-md text-base text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)] transition-colors"
|
class="flex items-center justify-center w-8 h-8 rounded-md text-base text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)] transition-colors"
|
||||||
title={collapsed ? 'Expand' : 'Collapse'}>
|
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||||
{collapsed ? '▶' : '◀'}
|
{collapsed ? '▶' : '◀'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,10 +109,10 @@
|
|||||||
{page.url.pathname === item.href
|
{page.url.pathname === item.href
|
||||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
||||||
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
||||||
title={collapsed ? tt(item.key) : ''}
|
title={collapsed ? t(item.key) : ''}
|
||||||
>
|
>
|
||||||
<span class="text-base">{item.icon}</span>
|
<MdiIcon name={item.icon} size={18} />
|
||||||
{#if !collapsed}{tt(item.key)}{/if}
|
{#if !collapsed}{t(item.key)}{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if auth.isAdmin}
|
{#if auth.isAdmin}
|
||||||
@@ -131,10 +122,10 @@
|
|||||||
{page.url.pathname === '/users'
|
{page.url.pathname === '/users'
|
||||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
||||||
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
||||||
title={collapsed ? tt('nav.users') : ''}
|
title={collapsed ? t('nav.users') : ''}
|
||||||
>
|
>
|
||||||
<span class="text-base">⊕</span>
|
<MdiIcon name="mdiAccountGroup" size={18} />
|
||||||
{#if !collapsed}{tt('nav.users')}{/if}
|
{#if !collapsed}{t('nav.users')}{/if}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -145,12 +136,12 @@
|
|||||||
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
|
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
|
||||||
<button onclick={toggleLocale}
|
<button onclick={toggleLocale}
|
||||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
||||||
title={tt('common.language')}>
|
title={t('common.language')}>
|
||||||
{getLocale().toUpperCase()}
|
{getLocale().toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={cycleTheme}
|
<button onclick={cycleTheme}
|
||||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
||||||
title={tt('common.theme')}>
|
title={t('common.theme')}>
|
||||||
{theme.resolved === 'dark' ? '🌙' : '☀️'}
|
{theme.resolved === 'dark' ? '🌙' : '☀️'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +151,7 @@
|
|||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={logout}
|
<button onclick={logout}
|
||||||
class="w-full flex justify-center py-2 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] rounded hover:bg-[var(--color-muted)] transition-colors"
|
class="w-full flex justify-center py-2 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] rounded hover:bg-[var(--color-muted)] transition-colors"
|
||||||
title={tt('nav.logout')}>
|
title={t('nav.logout')}>
|
||||||
⏻
|
⏻
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -172,13 +163,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<button onclick={logout}
|
<button onclick={logout}
|
||||||
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
||||||
title={tt('nav.logout')}>
|
title={t('nav.logout')}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => showPasswordForm = true}
|
<button onclick={() => showPasswordForm = true}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
|
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
|
||||||
🔑 {tt('common.changePassword')}
|
🔑 {t('common.changePassword')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -186,33 +177,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile bottom nav -->
|
||||||
|
<nav class="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-card)] border-t border-[var(--color-border)] flex justify-around py-1.5">
|
||||||
|
{#each navItems.slice(0, 5) as item}
|
||||||
|
<a href={item.href}
|
||||||
|
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs rounded-md transition-colors
|
||||||
|
{page.url.pathname === item.href
|
||||||
|
? 'text-[var(--color-accent-foreground)] font-medium'
|
||||||
|
: 'text-[var(--color-muted-foreground)]'}">
|
||||||
|
<MdiIcon name={item.icon} size={20} />
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<button onclick={logout}
|
||||||
|
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
<MdiIcon name="mdiLogout" size={20} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 overflow-auto">
|
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||||
<div class="max-w-5xl mx-auto p-6">
|
<div class="max-w-5xl mx-auto p-4 md:p-6">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Redirect in progress -->
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Password change modal (outside flex container) -->
|
<!-- Password change modal -->
|
||||||
<Modal open={showPasswordForm} title={tt('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; }}>
|
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
|
||||||
<form onsubmit={changePassword} class="space-y-3">
|
<form onsubmit={changePassword} class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{tt('common.currentPassword')}</label>
|
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{tt('common.newPassword')}</label>
|
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if pwdMsg}
|
{#if pwdMsg}
|
||||||
<p class="text-sm" style="color: var(--color-success-fg);">{pwdMsg}</p>
|
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity">
|
||||||
{tt('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -5,35 +5,79 @@
|
|||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
let status = $state<any>(null);
|
let status = $state<any>(null);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } });
|
let error = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
status = await api('/status');
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message || t('common.error');
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<Loading lines={4} />
|
<Loading lines={4} />
|
||||||
|
{:else if error}
|
||||||
|
<Card>
|
||||||
|
<div class="flex items-center gap-2 text-[var(--color-error-fg)]">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||||
|
<p class="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
{:else if status}
|
{:else if status}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||||
<Card>
|
<Card hover>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
|
<div class="flex items-center gap-3">
|
||||||
<p class="text-3xl font-semibold mt-1">{status.servers}</p>
|
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
||||||
|
<MdiIcon name="mdiServer" size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
|
||||||
|
<p class="text-2xl font-semibold">{status.servers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card hover>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
|
<div class="flex items-center gap-3">
|
||||||
<p class="text-3xl font-semibold mt-1">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
|
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
||||||
|
<MdiIcon name="mdiRadar" size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
|
||||||
|
<p class="text-2xl font-semibold">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card hover>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
|
<div class="flex items-center gap-3">
|
||||||
<p class="text-3xl font-semibold mt-1">{status.targets}</p>
|
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
||||||
|
<MdiIcon name="mdiTarget" size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
|
||||||
|
<p class="text-2xl font-semibold">{status.targets}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
|
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
|
||||||
{#if status.recent_events.length === 0}
|
{#if status.recent_events.length === 0}
|
||||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.noEvents')}</p></Card>
|
<Card>
|
||||||
|
<div class="flex flex-col items-center py-4 gap-2 text-[var(--color-muted-foreground)]">
|
||||||
|
<MdiIcon name="mdiCalendarBlank" size={32} />
|
||||||
|
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<Card>
|
<Card>
|
||||||
<div class="divide-y divide-[var(--color-border)]">
|
<div class="divide-y divide-[var(--color-border)]">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
|
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
|
||||||
<div class="flex justify-end gap-1 mb-4">
|
<div class="flex justify-end gap-1 mb-4">
|
||||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); window.location.reload(); }}
|
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||||
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
|
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
|
||||||
{getLocale().toUpperCase()}
|
{getLocale().toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -7,20 +8,28 @@
|
|||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
let servers = $state<any[]>([]);
|
let servers = $state<any[]>([]);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
|
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let loadError = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
|
|
||||||
let health = $state<Record<number, boolean | null>>({});
|
let health = $state<Record<number, boolean | null>>({});
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try { servers = await api('/servers'); } catch {} finally { loaded = true; }
|
try {
|
||||||
|
servers = await api('/servers');
|
||||||
|
loadError = '';
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError = err.message || t('servers.loadError');
|
||||||
|
} finally { loaded = true; }
|
||||||
// Ping all servers in background
|
// Ping all servers in background
|
||||||
for (const s of servers) {
|
for (const s of servers) {
|
||||||
health[s.id] = null; // loading
|
health[s.id] = null; // loading
|
||||||
@@ -52,8 +61,14 @@
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
function startDelete(server: any) {
|
||||||
if (!confirm(t('servers.confirmDelete'))) return;
|
confirmDelete = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
const id = confirmDelete.id;
|
||||||
|
confirmDelete = null;
|
||||||
try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -69,7 +84,14 @@
|
|||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<Card class="mb-6">
|
||||||
|
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<div transition:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
<form onsubmit={save} class="space-y-3">
|
<form onsubmit={save} class="space-y-3">
|
||||||
@@ -95,6 +117,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if servers.length === 0 && !showForm}
|
{#if servers.length === 0 && !showForm}
|
||||||
@@ -102,11 +125,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each servers as server}
|
{#each servers as server}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
|
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
|
||||||
title={health[server.id] === true ? 'Online' : health[server.id] === false ? 'Offline' : 'Checking...'}></span>
|
title={health[server.id] === true ? t('servers.online') : health[server.id] === false ? t('servers.offline') : t('servers.checking')}></span>
|
||||||
{#if server.icon}<MdiIcon name={server.icon} />{/if}
|
{#if server.icon}<MdiIcon name={server.icon} />{/if}
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{server.name}</p>
|
<p class="font-medium">{server.name}</p>
|
||||||
@@ -115,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button onclick={() => edit(server)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
<button onclick={() => edit(server)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||||
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
|
<button onclick={() => startDelete(server)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -124,3 +147,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
|
||||||
|
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
let targets = $state<any[]>([]);
|
let targets = $state<any[]>([]);
|
||||||
let trackingConfigs = $state<any[]>([]);
|
let trackingConfigs = $state<any[]>([]);
|
||||||
@@ -22,8 +24,12 @@
|
|||||||
tracking_config_id: 0, template_config_id: 0 });
|
tracking_config_id: 0, template_config_id: 0 });
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let headersError = $state('');
|
||||||
let testResult = $state('');
|
let testResult = $state('');
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
let showTelegramSettings = $state(false);
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -31,7 +37,8 @@
|
|||||||
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
|
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
|
||||||
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
|
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
|
||||||
]);
|
]);
|
||||||
} catch {} finally { loaded = true; }
|
loadError = '';
|
||||||
|
} catch (err: any) { loadError = err.message || t('common.loadError'); } finally { loaded = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBotChats() {
|
async function loadBotChats() {
|
||||||
@@ -39,7 +46,7 @@
|
|||||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
|
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
||||||
async function edit(tgt: any) {
|
async function edit(tgt: any) {
|
||||||
formType = tgt.type;
|
formType = tgt.type;
|
||||||
const c = tgt.config || {};
|
const c = tgt.config || {};
|
||||||
@@ -52,11 +59,11 @@
|
|||||||
tracking_config_id: tgt.tracking_config_id ?? 0,
|
tracking_config_id: tgt.tracking_config_id ?? 0,
|
||||||
template_config_id: tgt.template_config_id ?? 0,
|
template_config_id: tgt.template_config_id ?? 0,
|
||||||
};
|
};
|
||||||
editing = tgt.id; showForm = true;
|
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = '';
|
e.preventDefault(); error = ''; headersError = '';
|
||||||
try {
|
try {
|
||||||
let botToken = form.bot_token;
|
let botToken = form.bot_token;
|
||||||
// Resolve token from registered bot if selected
|
// Resolve token from registered bot if selected
|
||||||
@@ -64,6 +71,15 @@
|
|||||||
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
||||||
botToken = tokenRes.token;
|
botToken = tokenRes.token;
|
||||||
}
|
}
|
||||||
|
let parsedHeaders = {};
|
||||||
|
if (formType === 'webhook' && form.headers) {
|
||||||
|
try {
|
||||||
|
parsedHeaders = JSON.parse(form.headers);
|
||||||
|
} catch {
|
||||||
|
headersError = t('common.headersInvalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const config = formType === 'telegram'
|
const config = formType === 'telegram'
|
||||||
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
||||||
bot_id: form.bot_id || undefined,
|
bot_id: form.bot_id || undefined,
|
||||||
@@ -71,7 +87,7 @@
|
|||||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||||
ai_captions: form.ai_captions }
|
ai_captions: form.ai_captions }
|
||||||
: { url: form.url, headers: form.headers ? JSON.parse(form.headers) : {}, ai_captions: form.ai_captions };
|
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||||
const trkId = form.tracking_config_id || null;
|
const trkId = form.tracking_config_id || null;
|
||||||
const tplId = form.template_config_id || null;
|
const tplId = form.template_config_id || null;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -89,7 +105,6 @@
|
|||||||
setTimeout(() => testResult = '', 5000);
|
setTimeout(() => testResult = '', 5000);
|
||||||
}
|
}
|
||||||
async function remove(id: number) {
|
async function remove(id: number) {
|
||||||
if (!confirm(t('targets.confirmDelete'))) return;
|
|
||||||
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -103,11 +118,16 @@
|
|||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if testResult}
|
{#if testResult}
|
||||||
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes(t('targets.testSent')) ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]'}">{testResult}</div>
|
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes(t('targets.testSent')) ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]'}">{testResult}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<div transition:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
<form onsubmit={save} class="space-y-4">
|
<form onsubmit={save} class="space-y-4">
|
||||||
@@ -163,9 +183,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Telegram media settings -->
|
<!-- Telegram media settings -->
|
||||||
<details class="border border-[var(--color-border)] rounded-md p-3">
|
<div class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
<summary class="text-sm font-medium cursor-pointer">{t('targets.telegramSettings')}</summary>
|
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
|
||||||
<div class="grid grid-cols-2 gap-3 mt-3">
|
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
|
||||||
|
{t('targets.telegramSettings')}
|
||||||
|
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
|
||||||
|
</button>
|
||||||
|
{#if showTelegramSettings}
|
||||||
|
<div transition:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}</label>
|
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}</label>
|
||||||
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||||
@@ -185,12 +210,18 @@
|
|||||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
||||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
|
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
|
||||||
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
|
||||||
|
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
|
||||||
|
{#if headersError}<p class="text-xs text-[var(--color-destructive)] mt-1">{headersError}</p>{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Config assignments -->
|
<!-- Config assignments -->
|
||||||
@@ -198,14 +229,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label>
|
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label>
|
||||||
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||||
<option value={0}>— None —</option>
|
<option value={0}>— {t('common.none')} —</option>
|
||||||
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label>
|
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label>
|
||||||
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||||
<option value={0}>— None (default) —</option>
|
<option value={0}>— {t('common.noneDefault')} —</option>
|
||||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +247,7 @@
|
|||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('targets.create')}</button>
|
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('targets.create')}</button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if targets.length === 0 && !showForm}
|
{#if targets.length === 0 && !showForm}
|
||||||
@@ -223,7 +255,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each targets as target}
|
{#each targets as target}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -238,7 +270,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button onclick={() => edit(target)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
<button onclick={() => edit(target)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||||
<button onclick={() => test(target.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('targets.test')}</button>
|
<button onclick={() => test(target.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('targets.test')}</button>
|
||||||
<button onclick={() => remove(target.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
|
<button onclick={() => confirmDelete = target} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -247,3 +279,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!confirmDelete}
|
||||||
|
title={t('targets.confirmDelete')}
|
||||||
|
message={confirmDelete?.name ?? ''}
|
||||||
|
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
|
||||||
|
oncancel={() => confirmDelete = null}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
let bots = $state<any[]>([]);
|
let bots = $state<any[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
let form = $state({ name: '', icon: '', token: '' });
|
let form = $state({ name: '', icon: '', token: '' });
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
|
|
||||||
// Per-bot chat lists
|
// Per-bot chat lists
|
||||||
let chats = $state<Record<number, any[]>>({});
|
let chats = $state<Record<number, any[]>>({});
|
||||||
@@ -21,7 +23,11 @@
|
|||||||
let expandedBot = $state<number | null>(null);
|
let expandedBot = $state<number | null>(null);
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } }
|
async function load() {
|
||||||
|
try { bots = await api('/telegram-bots'); }
|
||||||
|
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||||
|
finally { loaded = true; }
|
||||||
|
}
|
||||||
|
|
||||||
async function create(e: SubmitEvent) {
|
async function create(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = ''; submitting = true;
|
e.preventDefault(); error = ''; submitting = true;
|
||||||
@@ -32,9 +38,15 @@
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
function remove(id: number) {
|
||||||
if (!confirm(t('common.delete') + '?')) return;
|
confirmDelete = {
|
||||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
id,
|
||||||
|
onconfirm: async () => {
|
||||||
|
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); }
|
||||||
|
catch (err: any) { error = err.message; }
|
||||||
|
finally { confirmDelete = null; }
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChats(botId: number) {
|
async function loadChats(botId: number) {
|
||||||
@@ -96,7 +108,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each bots as bot}
|
{#each bots as bot}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -149,3 +161,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||||
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -7,12 +8,14 @@
|
|||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
let configs = $state<any[]>([]);
|
let configs = $state<any[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
let previewSlot = $state('message_assets_added');
|
let previewSlot = $state('message_assets_added');
|
||||||
let previewResult = $state('');
|
let previewResult = $state('');
|
||||||
let previewId = $state<number | null>(null);
|
let previewId = $state<number | null>(null);
|
||||||
@@ -78,7 +81,11 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() { try { configs = await api('/template-configs'); } catch {} finally { loaded = true; } }
|
async function load() {
|
||||||
|
try { configs = await api('/template-configs'); }
|
||||||
|
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||||
|
finally { loaded = true; }
|
||||||
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||||
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
|
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
|
||||||
@@ -100,9 +107,15 @@
|
|||||||
} catch (err: any) { previewResult = `Error: ${err.message}`; }
|
} catch (err: any) { previewResult = `Error: ${err.message}`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
function remove(id: number) {
|
||||||
if (!confirm(t('common.delete') + '?')) return;
|
confirmDelete = {
|
||||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
id,
|
||||||
|
onconfirm: async () => {
|
||||||
|
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); }
|
||||||
|
catch (err: any) { error = err.message; }
|
||||||
|
finally { confirmDelete = null; }
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -116,6 +129,7 @@
|
|||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<div transition:slide>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
<form onsubmit={save} class="space-y-5">
|
<form onsubmit={save} class="space-y-5">
|
||||||
@@ -135,7 +149,7 @@
|
|||||||
{#each group.slots as slot}
|
{#each group.slots as slot}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
|
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
|
||||||
<textarea bind:value={form[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
|
<textarea bind:value={(form as any)[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
|
||||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -148,6 +162,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if configs.length === 0 && !showForm}
|
{#if configs.length === 0 && !showForm}
|
||||||
@@ -155,7 +170,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each configs as config}
|
{#each configs as config}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -182,3 +197,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||||
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -7,8 +8,10 @@
|
|||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
let trackers = $state<any[]>([]);
|
let trackers = $state<any[]>([]);
|
||||||
let servers = $state<any[]>([]);
|
let servers = $state<any[]>([]);
|
||||||
let targets = $state<any[]>([]);
|
let targets = $state<any[]>([]);
|
||||||
@@ -16,6 +19,12 @@
|
|||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let albumFilter = $state('');
|
let albumFilter = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
|
let toggling = $state<Record<number, boolean>>({});
|
||||||
|
let testingPeriodic = $state<Record<number, boolean>>({});
|
||||||
|
let testingMemory = $state<Record<number, boolean>>({});
|
||||||
|
let testFeedback = $state<Record<number, string>>({});
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
name: '', icon: '', server_id: 0, album_ids: [] as string[],
|
name: '', icon: '', server_id: 0, album_ids: [] as string[],
|
||||||
target_ids: [] as number[], scan_interval: 60,
|
target_ids: [] as number[], scan_interval: 60,
|
||||||
@@ -25,7 +34,14 @@
|
|||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try { [trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]); } catch {} finally { loaded = true; }
|
loadError = '';
|
||||||
|
try {
|
||||||
|
[trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]);
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError = err.message || 'Failed to load data';
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
|
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
|
||||||
|
|
||||||
@@ -41,6 +57,8 @@
|
|||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = '';
|
e.preventDefault(); error = '';
|
||||||
|
if (submitting) return;
|
||||||
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||||
@@ -48,14 +66,52 @@
|
|||||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||||
}
|
}
|
||||||
showForm = false; editing = null; await load();
|
showForm = false; editing = null; await load();
|
||||||
} catch (err: any) { error = err.message; }
|
} catch (err: any) { error = err.message; } finally { submitting = false; }
|
||||||
}
|
}
|
||||||
async function toggle(tracker: any) {
|
async function toggle(tracker: any) {
|
||||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) }); await load();
|
if (toggling[tracker.id]) return;
|
||||||
|
toggling[tracker.id] = true;
|
||||||
|
try {
|
||||||
|
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||||
|
await load();
|
||||||
|
} finally { toggling[tracker.id] = false; }
|
||||||
}
|
}
|
||||||
async function remove(id: number) {
|
function startDelete(tracker: any) { confirmDelete = tracker; }
|
||||||
if (!confirm(t('trackers.confirmDelete'))) return;
|
async function doDelete() {
|
||||||
try { await api(`/trackers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
if (!confirmDelete) return;
|
||||||
|
try {
|
||||||
|
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||||
|
await load();
|
||||||
|
} catch (err: any) { error = err.message; }
|
||||||
|
confirmDelete = null;
|
||||||
|
}
|
||||||
|
async function testPeriodic(tracker: any) {
|
||||||
|
if (testingPeriodic[tracker.id]) return;
|
||||||
|
testingPeriodic[tracker.id] = true;
|
||||||
|
testFeedback[tracker.id] = '';
|
||||||
|
try {
|
||||||
|
await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' });
|
||||||
|
testFeedback[tracker.id] = 'ok';
|
||||||
|
} catch {
|
||||||
|
testFeedback[tracker.id] = 'error';
|
||||||
|
} finally {
|
||||||
|
testingPeriodic[tracker.id] = false;
|
||||||
|
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function testMemory(tracker: any) {
|
||||||
|
if (testingMemory[tracker.id]) return;
|
||||||
|
testingMemory[tracker.id] = true;
|
||||||
|
testFeedback[tracker.id] = '';
|
||||||
|
try {
|
||||||
|
await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' });
|
||||||
|
testFeedback[tracker.id] = 'ok';
|
||||||
|
} catch {
|
||||||
|
testFeedback[tracker.id] = 'error';
|
||||||
|
} finally {
|
||||||
|
testingMemory[tracker.id] = false;
|
||||||
|
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
|
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
|
||||||
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
|
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
|
||||||
@@ -70,7 +126,17 @@
|
|||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<Loading />
|
<Loading />
|
||||||
|
{:else if loadError}
|
||||||
|
<Card>
|
||||||
|
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
<button onclick={load} class="mt-3 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||||
|
{t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
{:else if showForm}
|
{:else if showForm}
|
||||||
|
<div transition:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
<form onsubmit={save} class="space-y-4">
|
<form onsubmit={save} class="space-y-4">
|
||||||
@@ -127,9 +193,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
@@ -139,7 +206,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each trackers as tracker}
|
{#each trackers as tracker}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -149,20 +216,37 @@
|
|||||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} target(s)</p>
|
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} {t('trackers.targets')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button onclick={() => edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
<button onclick={() => edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
|
||||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}</button>
|
<button onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}</button>
|
||||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Periodic</button>
|
<button onclick={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||||
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Memory</button>
|
{testingPeriodic[tracker.id] ? '...' : t('trackers.testPeriodic')}
|
||||||
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
|
||||||
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
|
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => remove(tracker.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
|
<button onclick={() => testMemory(tracker)} disabled={testingMemory[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||||
|
{testingMemory[tracker.id] ? '...' : t('trackers.testMemory')}
|
||||||
|
</button>
|
||||||
|
{#if testFeedback[tracker.id]}
|
||||||
|
<span class="text-xs {testFeedback[tracker.id] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-destructive)]'}">
|
||||||
|
{testFeedback[tracker.id] === 'ok' ? '\u2713' : '\u2717'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<button onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||||
|
{toggling[tracker.id] ? '...' : tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||||
|
</button>
|
||||||
|
<button onclick={() => startDelete(tracker)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!confirmDelete}
|
||||||
|
title={t('trackers.delete')}
|
||||||
|
message={t('trackers.deleteConfirm')}
|
||||||
|
onconfirm={doDelete}
|
||||||
|
oncancel={() => confirmDelete = null}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -7,12 +8,14 @@
|
|||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
let configs = $state<any[]>([]);
|
let configs = $state<any[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
|
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
|
||||||
@@ -30,7 +33,11 @@
|
|||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() { try { configs = await api('/tracking-configs'); } catch {} finally { loaded = true; } }
|
async function load() {
|
||||||
|
try { configs = await api('/tracking-configs'); }
|
||||||
|
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||||
|
finally { loaded = true; }
|
||||||
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||||
function edit(c: any) {
|
function edit(c: any) {
|
||||||
@@ -47,9 +54,15 @@
|
|||||||
} catch (err: any) { error = err.message; }
|
} catch (err: any) { error = err.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
function remove(id: number) {
|
||||||
if (!confirm(t('common.delete') + '?')) return;
|
confirmDelete = {
|
||||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
id,
|
||||||
|
onconfirm: async () => {
|
||||||
|
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); }
|
||||||
|
catch (err: any) { error = err.message; }
|
||||||
|
finally { confirmDelete = null; }
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,6 +76,7 @@
|
|||||||
{#if !loaded}<Loading />{:else}
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<div transition:slide>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
<form onsubmit={save} class="space-y-5">
|
<form onsubmit={save} class="space-y-5">
|
||||||
@@ -104,13 +118,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
||||||
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||||
<option value="none">None</option><option value="date">Date</option><option value="rating">Rating</option><option value="name">Name</option>
|
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
||||||
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||||
<option value="descending">Desc</option><option value="ascending">Asc</option>
|
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,12 +152,12 @@
|
|||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
||||||
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||||
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
|
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||||
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||||
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
|
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||||
@@ -160,12 +174,12 @@
|
|||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
|
||||||
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||||
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
|
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||||
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||||
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
|
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
|
||||||
@@ -178,6 +192,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if configs.length === 0 && !showForm}
|
{#if configs.length === 0 && !showForm}
|
||||||
@@ -185,7 +200,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each configs as config}
|
{#each configs as config}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -210,3 +225,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||||
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
let users = $state<any[]>([]);
|
let users = $state<any[]>([]);
|
||||||
@@ -14,35 +15,48 @@
|
|||||||
let form = $state({ username: '', password: '', role: 'user' });
|
let form = $state({ username: '', password: '', role: 'user' });
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let confirmDelete = $state<any>(null);
|
||||||
|
|
||||||
// Admin reset password
|
// Admin reset password
|
||||||
let resetUserId = $state<number | null>(null);
|
let resetUserId = $state<number | null>(null);
|
||||||
let resetUsername = $state('');
|
let resetUsername = $state('');
|
||||||
let resetPassword = $state('');
|
let resetPassword = $state('');
|
||||||
let resetMsg = $state('');
|
let resetMsg = $state('');
|
||||||
|
let resetSuccess = $state(false);
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
|
async function load() {
|
||||||
|
try { users = await api('/users'); }
|
||||||
|
catch (err: any) { error = err.message || t('common.loadError'); }
|
||||||
|
finally { loaded = true; }
|
||||||
|
}
|
||||||
|
|
||||||
async function create(e: SubmitEvent) {
|
async function create(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = '';
|
e.preventDefault(); error = '';
|
||||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); }
|
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); }
|
||||||
catch (err: any) { error = err.message; }
|
catch (err: any) { error = err.message; }
|
||||||
}
|
}
|
||||||
async function remove(id: number) {
|
function remove(id: number) {
|
||||||
if (!confirm(t('users.confirmDelete'))) return;
|
confirmDelete = {
|
||||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); }
|
id,
|
||||||
|
onconfirm: async () => {
|
||||||
|
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); }
|
||||||
|
catch (err: any) { error = err.message; }
|
||||||
|
finally { confirmDelete = null; }
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function openResetPassword(user: any) {
|
function openResetPassword(user: any) {
|
||||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = '';
|
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||||
}
|
}
|
||||||
async function resetUserPassword(e: SubmitEvent) {
|
async function resetUserPassword(e: SubmitEvent) {
|
||||||
e.preventDefault(); resetMsg = '';
|
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||||
try {
|
try {
|
||||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||||
resetMsg = t('common.passwordChanged');
|
resetMsg = t('common.passwordChanged');
|
||||||
setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000);
|
resetSuccess = true;
|
||||||
} catch (err: any) { resetMsg = err.message; }
|
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||||
|
} catch (err: any) { resetMsg = err.message; resetSuccess = false; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -81,7 +95,7 @@
|
|||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<Card>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{user.username}</p>
|
<p class="font-medium">{user.username}</p>
|
||||||
@@ -101,7 +115,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Admin reset password modal -->
|
<!-- Admin reset password modal -->
|
||||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; }}>
|
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
@@ -109,10 +123,13 @@
|
|||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if resetMsg}
|
{#if resetMsg}
|
||||||
<p class="text-sm {resetMsg.includes(t('common.passwordChanged')) ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||||
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ async def _build_context(session: AsyncSession, chat_id: str) -> str:
|
|||||||
if trackers:
|
if trackers:
|
||||||
parts.append(f"Active trackers: {len(trackers)}")
|
parts.append(f"Active trackers: {len(trackers)}")
|
||||||
for t in trackers[:5]:
|
for t in trackers[:5]:
|
||||||
parts.append(f" - {t.name}: {len(t.album_ids)} album(s), events: {', '.join(t.event_types)}")
|
parts.append(f" - {t.name}: {len(t.album_ids)} album(s)")
|
||||||
|
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(5)
|
select(EventLog).order_by(EventLog.created_at.desc()).limit(5)
|
||||||
@@ -184,15 +184,19 @@ async def _get_summary_data(
|
|||||||
"""Fetch data for album summary."""
|
"""Fetch data for album summary."""
|
||||||
albums_data: list[dict[str, Any]] = []
|
albums_data: list[dict[str, Any]] = []
|
||||||
servers_result = await session.exec(select(ImmichServer).limit(5))
|
servers_result = await session.exec(select(ImmichServer).limit(5))
|
||||||
for server in servers_result.all():
|
servers = servers_result.all()
|
||||||
try:
|
try:
|
||||||
from immich_watcher_core.immich_client import ImmichClient
|
from immich_watcher_core.immich_client import ImmichClient
|
||||||
async with aiohttp.ClientSession() as http_session:
|
async with aiohttp.ClientSession() as http_session:
|
||||||
client = ImmichClient(http_session, server.url, server.api_key)
|
for server in servers:
|
||||||
albums = await client.get_albums()
|
try:
|
||||||
albums_data.extend(albums[:20])
|
client = ImmichClient(http_session, server.url, server.api_key)
|
||||||
except Exception:
|
albums = await client.get_albums()
|
||||||
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
|
albums_data.extend(albums[:20])
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("Failed to create HTTP session for summary")
|
||||||
|
|
||||||
events_result = await session.exec(
|
events_result = await session.exec(
|
||||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(20)
|
select(EventLog).order_by(EventLog.created_at.desc()).limit(20)
|
||||||
|
|||||||
@@ -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)
|
server = await _get_user_server(session, server_id, user.id)
|
||||||
if body.name is not None:
|
if body.name is not None:
|
||||||
server.name = body.name
|
server.name = body.name
|
||||||
|
url_changed = body.url is not None and body.url != server.url
|
||||||
|
key_changed = body.api_key is not None and body.api_key != server.api_key
|
||||||
if body.url is not None:
|
if body.url is not None:
|
||||||
server.url = body.url
|
server.url = body.url
|
||||||
if body.api_key is not None:
|
if body.api_key is not None:
|
||||||
server.api_key = body.api_key
|
server.api_key = body.api_key
|
||||||
|
# Re-validate and refresh external_domain when URL or API key changes
|
||||||
|
if url_changed or key_changed:
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as http_session:
|
||||||
|
client = ImmichClient(http_session, server.url, server.api_key)
|
||||||
|
if not await client.ping():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Cannot connect to Immich server at {server.url}",
|
||||||
|
)
|
||||||
|
server.external_domain = await client.get_server_config()
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Connection error: {err}",
|
||||||
|
)
|
||||||
session.add(server)
|
session.add(server)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(server)
|
await session.refresh(server)
|
||||||
|
|||||||
@@ -52,12 +52,9 @@ class SyncTrackerResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
server_url: str
|
server_url: str
|
||||||
server_api_key: str
|
|
||||||
album_ids: list[str]
|
album_ids: list[str]
|
||||||
event_types: list[str]
|
|
||||||
scan_interval: int
|
scan_interval: int
|
||||||
enabled: bool
|
enabled: bool
|
||||||
template_body: str | None = None
|
|
||||||
targets: list[dict] = []
|
targets: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -84,40 +81,60 @@ async def get_sync_trackers(
|
|||||||
)
|
)
|
||||||
trackers = result.all()
|
trackers = result.all()
|
||||||
|
|
||||||
|
# Batch-load servers and targets to avoid N+1 queries
|
||||||
|
server_ids = {t.server_id for t in trackers}
|
||||||
|
all_target_ids = {tid for t in trackers for tid in t.target_ids}
|
||||||
|
|
||||||
|
servers_result = await session.exec(
|
||||||
|
select(ImmichServer).where(ImmichServer.id.in_(server_ids))
|
||||||
|
)
|
||||||
|
servers_map = {s.id: s for s in servers_result.all()}
|
||||||
|
|
||||||
|
targets_result = await session.exec(
|
||||||
|
select(NotificationTarget).where(NotificationTarget.id.in_(all_target_ids))
|
||||||
|
)
|
||||||
|
targets_map = {t.id: t for t in targets_result.all()}
|
||||||
|
|
||||||
responses = []
|
responses = []
|
||||||
for tracker in trackers:
|
for tracker in trackers:
|
||||||
# Fetch server details
|
server = servers_map.get(tracker.server_id)
|
||||||
server = await session.get(ImmichServer, tracker.server_id)
|
|
||||||
if not server:
|
if not server:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Fetch target configs
|
|
||||||
targets = []
|
targets = []
|
||||||
for target_id in tracker.target_ids:
|
for target_id in tracker.target_ids:
|
||||||
target = await session.get(NotificationTarget, target_id)
|
target = targets_map.get(target_id)
|
||||||
if target:
|
if target:
|
||||||
targets.append({
|
targets.append({
|
||||||
"type": target.type,
|
"type": target.type,
|
||||||
"name": target.name,
|
"name": target.name,
|
||||||
"config": target.config,
|
"config": _safe_target_config(target),
|
||||||
})
|
})
|
||||||
|
|
||||||
responses.append(SyncTrackerResponse(
|
responses.append(SyncTrackerResponse(
|
||||||
id=tracker.id,
|
id=tracker.id,
|
||||||
name=tracker.name,
|
name=tracker.name,
|
||||||
server_url=server.url,
|
server_url=server.url,
|
||||||
server_api_key=server.api_key,
|
|
||||||
album_ids=tracker.album_ids,
|
album_ids=tracker.album_ids,
|
||||||
event_types=[], # Event types now on tracking configs
|
|
||||||
scan_interval=tracker.scan_interval,
|
scan_interval=tracker.scan_interval,
|
||||||
enabled=tracker.enabled,
|
enabled=tracker.enabled,
|
||||||
template_body=None,
|
|
||||||
targets=targets,
|
targets=targets,
|
||||||
))
|
))
|
||||||
|
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_target_config(target: NotificationTarget) -> dict:
|
||||||
|
"""Return config with sensitive fields masked."""
|
||||||
|
config = dict(target.config)
|
||||||
|
if "bot_token" in config:
|
||||||
|
token = config["bot_token"]
|
||||||
|
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
|
||||||
|
if "api_key" in config:
|
||||||
|
config["api_key"] = "***"
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
@router.post("/templates/{template_id}/render")
|
@router.post("/templates/{template_id}/render")
|
||||||
async def render_template(
|
async def render_template(
|
||||||
template_id: int,
|
template_id: int,
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ async def update_target(
|
|||||||
target.name = body.name
|
target.name = body.name
|
||||||
if body.config is not None:
|
if body.config is not None:
|
||||||
target.config = body.config
|
target.config = body.config
|
||||||
|
if body.tracking_config_id is not None:
|
||||||
|
target.tracking_config_id = body.tracking_config_id
|
||||||
|
if body.template_config_id is not None:
|
||||||
|
target.template_config_id = body.template_config_id
|
||||||
session.add(target)
|
session.add(target)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(target)
|
await session.refresh(target)
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ async def get_bot_token(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Get the full bot token (for internal use by targets)."""
|
"""Get the full bot token (used by frontend to construct target config)."""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
|
# Token is returned only to the authenticated owner
|
||||||
return {"token": bot.token}
|
return {"token": bot.token}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
"""Album tracker management API routes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -150,7 +150,7 @@ async def test_memory(
|
|||||||
@router.get("/{tracker_id}/history")
|
@router.get("/{tracker_id}/history")
|
||||||
async def tracker_history(
|
async def tracker_history(
|
||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
limit: int = 20,
|
limit: int = Query(default=20, ge=1, le=500),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Server configuration from environment variables."""
|
"""Server configuration from environment variables."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -11,8 +12,16 @@ class Settings(BaseSettings):
|
|||||||
data_dir: Path = Path("/data")
|
data_dir: Path = Path("/data")
|
||||||
database_url: str = "" # Computed from data_dir if empty
|
database_url: str = "" # Computed from data_dir if empty
|
||||||
|
|
||||||
# JWT
|
# JWT (MUST be overridden via IMMICH_WATCHER_SECRET_KEY env var)
|
||||||
secret_key: str = "change-me-in-production"
|
secret_key: str = "change-me-in-production"
|
||||||
|
|
||||||
|
def model_post_init(self, __context: Any) -> None:
|
||||||
|
if self.secret_key == "change-me-in-production" and not self.debug:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).critical(
|
||||||
|
"SECURITY: Using default secret_key in non-debug mode! "
|
||||||
|
"Set IMMICH_WATCHER_SECRET_KEY environment variable."
|
||||||
|
)
|
||||||
access_token_expire_minutes: int = 60
|
access_token_expire_minutes: int = 60
|
||||||
refresh_token_expire_days: int = 30
|
refresh_token_expire_days: int = 30
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS for frontend dev server
|
# CORS: restrict to same-origin in production, allow all in debug mode
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"] if settings.debug else [],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -166,16 +166,28 @@ async def _check_album(
|
|||||||
)
|
)
|
||||||
session.add(event_log)
|
session.add(event_log)
|
||||||
|
|
||||||
# Send notifications to each target, filtered by its tracking config
|
# Batch-load targets, tracking configs, and template configs
|
||||||
for target_id in target_ids:
|
targets_result = await session.exec(
|
||||||
target = await session.get(NotificationTarget, target_id)
|
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
|
||||||
if not target:
|
)
|
||||||
continue
|
targets = targets_result.all()
|
||||||
|
|
||||||
# Check target's tracking config for event filtering
|
tc_ids = {t.tracking_config_id for t in targets if t.tracking_config_id}
|
||||||
tracking_config = None
|
tmpl_ids = {t.template_config_id for t in targets if t.template_config_id}
|
||||||
if target.tracking_config_id:
|
|
||||||
tracking_config = await session.get(TrackingConfig, target.tracking_config_id)
|
tracking_configs_map: dict[int, TrackingConfig] = {}
|
||||||
|
if tc_ids:
|
||||||
|
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
|
||||||
|
tracking_configs_map = {tc.id: tc for tc in tc_result.all()}
|
||||||
|
|
||||||
|
template_configs_map: dict[int, TemplateConfig] = {}
|
||||||
|
if tmpl_ids:
|
||||||
|
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
|
||||||
|
template_configs_map = {tc.id: tc for tc in tmpl_result.all()}
|
||||||
|
|
||||||
|
# Send notifications to each target, filtered by its tracking config
|
||||||
|
for target in targets:
|
||||||
|
tracking_config = tracking_configs_map.get(target.tracking_config_id) if target.tracking_config_id else None
|
||||||
|
|
||||||
if tracking_config:
|
if tracking_config:
|
||||||
# Filter by event type
|
# Filter by event type
|
||||||
@@ -193,16 +205,13 @@ async def _check_album(
|
|||||||
if not should_notify:
|
if not should_notify:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get target's template config
|
template_config = template_configs_map.get(target.template_config_id) if target.template_config_id else None
|
||||||
template_config = None
|
|
||||||
if target.template_config_id:
|
|
||||||
template_config = await session.get(TemplateConfig, target.template_config_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
use_ai = target.config.get("ai_captions", False)
|
use_ai = target.config.get("ai_captions", False)
|
||||||
await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
|
await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Failed to send notification to target %d", target_id)
|
_LOGGER.exception("Failed to send notification to target %d", target.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"album_id": album_id,
|
"album_id": album_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user