feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
This commit is contained in:
+96
-1
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
|
||||
export interface BlockedByDetail {
|
||||
message: string;
|
||||
entity: string;
|
||||
blocked_by: string[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
blockedBy?: BlockedByDetail;
|
||||
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.blockedBy = blockedBy;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
|
||||
export function parseDate(dateStr: string): Date {
|
||||
if (!dateStr) return new Date(NaN);
|
||||
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
|
||||
export function getBlockedBy(err: unknown): BlockedByDetail | null {
|
||||
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
@@ -106,7 +137,17 @@ export async function api<T = any>(
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
// Structured blocked-by detail (from delete_protection.raise_if_used)
|
||||
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||
const bb: BlockedByDetail = {
|
||||
message: err.detail.message || `HTTP ${res.status}`,
|
||||
entity: err.detail.entity || '',
|
||||
blocked_by: err.detail.blocked_by,
|
||||
};
|
||||
throw new ApiError(bb.message, res.status, bb);
|
||||
}
|
||||
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
@@ -114,3 +155,57 @@ export async function api<T = any>(
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
|
||||
* typically multipart/form-data uploads or binary downloads where we need the
|
||||
* raw ``Response`` object rather than parsed JSON.
|
||||
*
|
||||
* - Injects the Bearer token automatically.
|
||||
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
|
||||
* decides the encoding; browsers add the boundary).
|
||||
* - Attempts a one-shot token refresh on 401, matching ``api()``.
|
||||
* - Translates non-OK responses to ``ApiError`` so callers can use the same
|
||||
* ``getBlockedBy`` / ``err.message`` handling pattern.
|
||||
*/
|
||||
export async function fetchAuth(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
let res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(url, { ...options, headers });
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new ApiError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
|
||||
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||
const bb: BlockedByDetail = {
|
||||
message: err.detail.message || `HTTP ${res.status}`,
|
||||
entity: err.detail.entity || '',
|
||||
blocked_by: err.detail.blocked_by,
|
||||
};
|
||||
throw new ApiError(bb.message, res.status, bb);
|
||||
}
|
||||
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { BlockedByDetail } from '$lib/api';
|
||||
|
||||
let { open = false, detail = null, onclose } = $props<{
|
||||
open: boolean;
|
||||
detail: BlockedByDetail | null;
|
||||
onclose: () => void;
|
||||
}>();
|
||||
|
||||
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
|
||||
</script>
|
||||
|
||||
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
|
||||
{#if detail}
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiLinkVariant" size={20} />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
|
||||
{#if detail.entity}
|
||||
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
|
||||
{#if blockedCount > 0}
|
||||
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);">
|
||||
{blockedCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if blockedCount > 0}
|
||||
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
|
||||
{#each detail.blocked_by as consumer}
|
||||
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
|
||||
style="background: var(--color-muted); border: 1px solid var(--color-border);">
|
||||
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiChevronRight" size={14} />
|
||||
</span>
|
||||
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<button onclick={onclose} class="blocked-by-close-btn">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.blocked-by-close-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.blocked-by-close-btn:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { parseDate } from '$lib/api';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface DayData {
|
||||
@@ -47,7 +48,7 @@
|
||||
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const d = parseDate(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
||||
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
|
||||
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
|
||||
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
|
||||
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
||||
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
||||
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
||||
];
|
||||
|
||||
// --- Sort filter (dashboard) ---
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Events",
|
||||
"clearEvents": "Clear",
|
||||
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
|
||||
"chart": "Event chart",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||
"loading": "Loading...",
|
||||
@@ -76,6 +78,9 @@
|
||||
"collectionRenamed": "collection renamed",
|
||||
"collectionDeleted": "collection deleted",
|
||||
"sharingChanged": "sharing changed",
|
||||
"actionSuccess": "action run",
|
||||
"actionPartial": "action partial",
|
||||
"actionFailed": "action failed",
|
||||
"searchEvents": "Search events...",
|
||||
"allEvents": "All Events",
|
||||
"filterAssetsAdded": "Assets Added",
|
||||
@@ -83,6 +88,9 @@
|
||||
"filterRenamed": "Renamed",
|
||||
"filterDeleted": "Deleted",
|
||||
"filterSharingChanged": "Sharing Changed",
|
||||
"filterActionSuccess": "Action Success",
|
||||
"filterActionPartial": "Action Partial",
|
||||
"filterActionFailed": "Action Failed",
|
||||
"allProviders": "All Providers",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
@@ -365,6 +373,7 @@
|
||||
"roleAdmin": "Admin",
|
||||
"create": "Create User",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit user",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined",
|
||||
"noUsers": "No users found"
|
||||
@@ -785,13 +794,21 @@
|
||||
"disabled": "Disabled",
|
||||
"noListeners": "No listeners attached.",
|
||||
"selectBot": "Select bot...",
|
||||
"listenerType": "telegram_bot"
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Edit album scope",
|
||||
"scopeAll": "all albums",
|
||||
"albumsShort": "albums",
|
||||
"scopeTitle": "Album Scope for This Chat",
|
||||
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
|
||||
"scopeInherit": "Inherit: allow all tracked albums",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "{count} event(s) cleared",
|
||||
"providerSaved": "Provider saved",
|
||||
"providerDeleted": "Provider deleted",
|
||||
"trackerCreated": "Tracker created",
|
||||
@@ -810,6 +827,7 @@
|
||||
"botDeleted": "Bot deleted",
|
||||
"userCreated": "User created",
|
||||
"userDeleted": "User deleted",
|
||||
"userUpdated": "User updated",
|
||||
"passwordChanged": "Password changed",
|
||||
"copied": "Copied to clipboard",
|
||||
"genericError": "Something went wrong",
|
||||
@@ -827,6 +845,7 @@
|
||||
"commandTrackerDisabled": "Command tracker disabled",
|
||||
"listenerAdded": "Listener added",
|
||||
"listenerRemoved": "Listener removed",
|
||||
"listenerScopeSaved": "Scope updated",
|
||||
"cmdTemplateSaved": "Command template saved",
|
||||
"cmdTemplateDeleted": "Command template deleted",
|
||||
"emailBotCreated": "Email bot created",
|
||||
@@ -848,6 +867,8 @@
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"none": "None",
|
||||
@@ -960,6 +981,9 @@
|
||||
"renamed": "Album was renamed",
|
||||
"deleted": "Album was deleted",
|
||||
"sharingChanged": "Album sharing toggled",
|
||||
"actionSuccess": "Scheduled action completed",
|
||||
"actionPartial": "Scheduled action partially succeeded",
|
||||
"actionFailed": "Scheduled action failed",
|
||||
"newestFirst": "Most recent events on top",
|
||||
"oldestFirst": "Oldest events on top",
|
||||
"chatActionNone": "No indicator shown",
|
||||
@@ -1021,6 +1045,7 @@
|
||||
"name": "Name",
|
||||
"schedule": "Schedule",
|
||||
"interval": "Interval",
|
||||
"cronMode": "Cron expression",
|
||||
"seconds": "seconds",
|
||||
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
|
||||
"enabled": "Enabled",
|
||||
@@ -1126,6 +1151,18 @@
|
||||
"savedFiles": "Saved Backups",
|
||||
"noFiles": "No backup files yet.",
|
||||
"download": "Download",
|
||||
"fileDeleted": "Backup file deleted"
|
||||
"fileDeleted": "Backup file deleted",
|
||||
"createManual": "Create backup",
|
||||
"manualCreated": "Backup created",
|
||||
"pendingTitle": "Restore pending — restart to apply",
|
||||
"pendingBy": "Uploaded by {by}",
|
||||
"pendingAt": "at {at}",
|
||||
"pendingCancelled": "Pending restore cancelled",
|
||||
"restorePrepared": "Restore prepared",
|
||||
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
|
||||
"applyLater": "Apply later",
|
||||
"restartNow": "Restart now",
|
||||
"restartingTitle": "Restarting backend…",
|
||||
"restartingDescription": "The page will reload once the server is back online."
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "События",
|
||||
"clearEvents": "Очистить",
|
||||
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
|
||||
"chart": "График событий",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||
"loading": "Загрузка...",
|
||||
@@ -76,6 +78,9 @@
|
||||
"collectionRenamed": "альбом переименован",
|
||||
"collectionDeleted": "альбом удалён",
|
||||
"sharingChanged": "изменение доступа",
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
@@ -83,6 +88,9 @@
|
||||
"filterRenamed": "Переименование",
|
||||
"filterDeleted": "Удаление",
|
||||
"filterSharingChanged": "Изменение доступа",
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
@@ -365,6 +373,7 @@
|
||||
"roleAdmin": "Администратор",
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать пользователя",
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован",
|
||||
"noUsers": "Пользователи не найдены"
|
||||
@@ -785,13 +794,21 @@
|
||||
"disabled": "Отключён",
|
||||
"noListeners": "Нет подключённых слушателей.",
|
||||
"selectBot": "Выберите бота...",
|
||||
"listenerType": "telegram_bot"
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Изменить область альбомов",
|
||||
"scopeAll": "все альбомы",
|
||||
"albumsShort": "альбомов",
|
||||
"scopeTitle": "Область альбомов для этого чата",
|
||||
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
|
||||
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "Очищено событий: {count}",
|
||||
"providerSaved": "Провайдер сохранён",
|
||||
"providerDeleted": "Провайдер удалён",
|
||||
"trackerCreated": "Трекер создан",
|
||||
@@ -810,6 +827,7 @@
|
||||
"botDeleted": "Бот удалён",
|
||||
"userCreated": "Пользователь создан",
|
||||
"userDeleted": "Пользователь удалён",
|
||||
"userUpdated": "Пользователь обновлён",
|
||||
"passwordChanged": "Пароль изменён",
|
||||
"copied": "Скопировано",
|
||||
"genericError": "Что-то пошло не так",
|
||||
@@ -827,6 +845,7 @@
|
||||
"commandTrackerDisabled": "Трекер команд отключён",
|
||||
"listenerAdded": "Слушатель добавлен",
|
||||
"listenerRemoved": "Слушатель удалён",
|
||||
"listenerScopeSaved": "Область обновлена",
|
||||
"cmdTemplateSaved": "Шаблон команд сохранён",
|
||||
"cmdTemplateDeleted": "Шаблон команд удалён",
|
||||
"emailBotCreated": "Email бот создан",
|
||||
@@ -848,6 +867,8 @@
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
"none": "Нет",
|
||||
@@ -960,6 +981,9 @@
|
||||
"renamed": "Альбом переименован",
|
||||
"deleted": "Альбом удалён",
|
||||
"sharingChanged": "Изменён доступ к альбому",
|
||||
"actionSuccess": "Запланированное действие выполнено",
|
||||
"actionPartial": "Запланированное действие выполнено частично",
|
||||
"actionFailed": "Запланированное действие провалено",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
@@ -1021,6 +1045,7 @@
|
||||
"name": "Название",
|
||||
"schedule": "Расписание",
|
||||
"interval": "Интервал",
|
||||
"cronMode": "Cron выражение",
|
||||
"seconds": "секунд",
|
||||
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
|
||||
"enabled": "Включено",
|
||||
@@ -1126,6 +1151,18 @@
|
||||
"savedFiles": "Сохранённые бэкапы",
|
||||
"noFiles": "Файлов бэкапа пока нет.",
|
||||
"download": "Скачать",
|
||||
"fileDeleted": "Файл бэкапа удалён"
|
||||
"fileDeleted": "Файл бэкапа удалён",
|
||||
"createManual": "Создать бэкап",
|
||||
"manualCreated": "Бэкап создан",
|
||||
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
|
||||
"pendingBy": "Загружено пользователем {by}",
|
||||
"pendingAt": "в {at}",
|
||||
"pendingCancelled": "Ожидающее восстановление отменено",
|
||||
"restorePrepared": "Восстановление подготовлено",
|
||||
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
|
||||
"applyLater": "Применить позже",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"restartingTitle": "Перезапуск бэкенда…",
|
||||
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,10 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
||||
const warnings: { id: string; name: string; issue: string }[] = [];
|
||||
|
||||
for (const albumId of newIds) {
|
||||
// Run shared-link checks in parallel with a concurrency cap so a large
|
||||
// album set doesn't stall the save button for seconds.
|
||||
const CONCURRENCY = 6;
|
||||
async function checkOne(albumId: string): Promise<void> {
|
||||
try {
|
||||
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
||||
@@ -123,6 +126,19 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
} catch { /* shared-link check failed, proceed */ }
|
||||
}
|
||||
|
||||
const queue = [...newIds];
|
||||
const workers: Promise<void>[] = [];
|
||||
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
|
||||
workers.push((async () => {
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (next === undefined) return;
|
||||
await checkOne(next);
|
||||
}
|
||||
})());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
|
||||
if (warnings.length > 0) return { warnings, proceed: false };
|
||||
return { proceed: true };
|
||||
},
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Global provider filter -->
|
||||
{#if allProviders.length > 1}
|
||||
{#if allProviders.length >= 1}
|
||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
@@ -564,7 +564,7 @@
|
||||
onclick={() => mobileMoreOpen = false} role="presentation"></div>
|
||||
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
|
||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||
{#if allProviders.length > 1}
|
||||
{#if allProviders.length >= 1}
|
||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -11,6 +11,8 @@
|
||||
import EventChart from '$lib/components/EventChart.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
@@ -56,6 +58,21 @@
|
||||
let eventsLimit = $state(loadEventsPerPage());
|
||||
let eventsOffset = $state(0);
|
||||
let eventsLoading = $state(false);
|
||||
let confirmClearEvents = $state(false);
|
||||
|
||||
async function clearEvents() {
|
||||
try {
|
||||
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
|
||||
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
|
||||
eventsOffset = 0;
|
||||
await loadEvents();
|
||||
await loadChart();
|
||||
} catch (err: any) {
|
||||
snackError(err.message || t('common.error'));
|
||||
} finally {
|
||||
confirmClearEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
||||
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
||||
@@ -191,7 +208,7 @@
|
||||
] : []);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const diff = Date.now() - parseDate(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return t('dashboard.justNow');
|
||||
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
||||
@@ -206,15 +223,20 @@
|
||||
collection_renamed: 'dashboard.collectionRenamed',
|
||||
collection_deleted: 'dashboard.collectionDeleted',
|
||||
sharing_changed: 'dashboard.sharingChanged',
|
||||
action_success: 'dashboard.actionSuccess',
|
||||
action_partial: 'dashboard.actionPartial',
|
||||
action_failed: 'dashboard.actionFailed',
|
||||
};
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||
};
|
||||
const eventColors: Record<string, string> = {
|
||||
assets_added: '#059669', assets_removed: '#ef4444',
|
||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
||||
};
|
||||
|
||||
</script>
|
||||
@@ -252,13 +274,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Events section -->
|
||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
{#if status.total_events > 0}
|
||||
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||
{/if}
|
||||
</h3>
|
||||
{#if status.total_events > 0}
|
||||
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||
<button type="button" onclick={() => confirmClearEvents = true}
|
||||
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
|
||||
title={t('dashboard.clearEvents')}>
|
||||
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
||||
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
@@ -370,6 +402,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||
|
||||
<style>
|
||||
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
||||
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
@@ -385,4 +420,6 @@
|
||||
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
||||
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
||||
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
||||
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
|
||||
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import type { ActionExecution } from '$lib/types';
|
||||
@@ -47,14 +47,14 @@
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
return parseDate(iso).toLocaleString();
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string | null): string {
|
||||
if (!end) return '-';
|
||||
try {
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
const ms = parseDate(end).getTime() - parseDate(start).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
} catch { return '-'; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -58,12 +59,17 @@
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function removeEmail(id: number) {
|
||||
confirmDeleteEmail = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDeleteEmail = null; }
|
||||
}
|
||||
};
|
||||
@@ -173,3 +179,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -56,12 +57,17 @@
|
||||
finally { matrixSubmitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function removeMatrix(id: number) {
|
||||
confirmDeleteMatrix = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDeleteMatrix = null; }
|
||||
}
|
||||
};
|
||||
@@ -155,3 +161,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -64,12 +65,17 @@
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
@@ -518,3 +524,5 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -92,7 +93,10 @@
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
// Auto-select first matching template for the default provider_type
|
||||
// Auto-select first provider type with commands
|
||||
const types = Object.keys(allCapabilities).filter(t => (allCapabilities[t]?.commands?.length || 0) > 0);
|
||||
if (types.length > 0) form.provider_type = types[0];
|
||||
// Auto-select first matching template for the chosen provider_type
|
||||
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
|
||||
if (match) form.command_template_config_id = match.id;
|
||||
editing = null;
|
||||
@@ -137,6 +141,7 @@
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(cfg: CommandConfig) {
|
||||
confirmDelete = {
|
||||
id: cfg.id,
|
||||
@@ -145,7 +150,11 @@
|
||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandConfigDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
@@ -296,3 +305,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -189,6 +190,8 @@
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
||||
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
@@ -265,6 +268,7 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
@@ -274,6 +278,8 @@
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -458,6 +464,8 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
@@ -83,7 +84,17 @@
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
|
||||
if (firstCfg) form.command_config_id = firstCfg.id;
|
||||
}
|
||||
editing = null;
|
||||
showForm = true;
|
||||
}
|
||||
function editTracker(trk: any) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
@@ -178,6 +189,35 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
// Per-listener album scope editing
|
||||
let scopeEditor = $state<{ trkId: number; listener: any; providerId: number; collections: any[]; selectedIds: string[]; inherit: boolean } | null>(null);
|
||||
async function openScopeEditor(trkId: number, listener: any) {
|
||||
const trk = allCmdTrackers.find((t: any) => t.id === trkId);
|
||||
if (!trk) return;
|
||||
let collections: any[] = [];
|
||||
try { collections = await api(`/providers/${trk.provider_id}/collections`); } catch { /* ignore */ }
|
||||
scopeEditor = {
|
||||
trkId,
|
||||
listener,
|
||||
providerId: trk.provider_id,
|
||||
collections,
|
||||
selectedIds: [...(listener.allowed_album_ids || [])],
|
||||
inherit: listener.allowed_album_ids === null || listener.allowed_album_ids === undefined,
|
||||
};
|
||||
}
|
||||
async function saveScope() {
|
||||
if (!scopeEditor) return;
|
||||
const body = { allowed_album_ids: scopeEditor.inherit ? null : scopeEditor.selectedIds };
|
||||
try {
|
||||
await api(`/command-trackers/${scopeEditor.trkId}/listeners/${scopeEditor.listener.id}`, {
|
||||
method: 'PATCH', body: JSON.stringify(body),
|
||||
});
|
||||
snackSuccess(t('snack.listenerScopeSaved'));
|
||||
await loadListeners(scopeEditor.trkId);
|
||||
scopeEditor = null;
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
function providerName(id: number): string {
|
||||
return providers.find(p => p.id === id)?.name || '?';
|
||||
}
|
||||
@@ -289,10 +329,18 @@
|
||||
<div class="space-y-1">
|
||||
{#each listeners[trk.id] as listener}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MdiIcon name="mdiRobot" size={14} />
|
||||
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
|
||||
<button type="button" onclick={() => openScopeEditor(trk.id, listener)}
|
||||
class="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]"
|
||||
title={t('commandTracker.editScope')}>
|
||||
<MdiIcon name="mdiImageMultiple" size={12} />
|
||||
{listener.allowed_album_ids === null || listener.allowed_album_ids === undefined
|
||||
? t('commandTracker.scopeAll')
|
||||
: `${(listener.allowed_album_ids || []).length} ${t('commandTracker.albumsShort')}`}
|
||||
</button>
|
||||
</div>
|
||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||
@@ -321,3 +369,59 @@
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<!-- Per-listener album scope editor -->
|
||||
<Modal open={scopeEditor !== null} title={t('commandTracker.scopeTitle')} onclose={() => scopeEditor = null}>
|
||||
{#if scopeEditor}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('commandTracker.scopeDescription')}</p>
|
||||
<label class="flex items-center gap-2 text-sm mb-3">
|
||||
<input type="checkbox" bind:checked={scopeEditor.inherit} />
|
||||
{t('commandTracker.scopeInherit')}
|
||||
</label>
|
||||
{#if !scopeEditor.inherit}
|
||||
{#if scopeEditor.collections.length > 0}
|
||||
<div class="flex items-center justify-between mb-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||
<span>{scopeEditor.selectedIds.length} / {scopeEditor.collections.length}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
||||
{t('backup.selectAll')}
|
||||
</button>
|
||||
<span aria-hidden="true">·</span>
|
||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
||||
{t('backup.deselectAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-1 max-h-72 overflow-y-auto border border-[var(--color-border)] rounded-md p-2">
|
||||
{#if scopeEditor.collections.length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] py-3 text-center">{t('commandTracker.noCollections')}</p>
|
||||
{:else}
|
||||
{#each scopeEditor.collections as col}
|
||||
{@const cid = col.id}
|
||||
<label class="flex items-center gap-2 text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer">
|
||||
<input type="checkbox" checked={scopeEditor.selectedIds.includes(cid)}
|
||||
onchange={(e) => {
|
||||
if (!scopeEditor) return;
|
||||
const target = e.target as HTMLInputElement;
|
||||
scopeEditor.selectedIds = target.checked
|
||||
? [...scopeEditor.selectedIds, cid]
|
||||
: scopeEditor.selectedIds.filter((i) => i !== cid);
|
||||
}} />
|
||||
<span class="truncate min-w-0 flex-1" title={col.albumName || col.name || cid}>{col.albumName || col.name || cid}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<button onclick={() => scopeEditor = null}
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<Button size="sm" onclick={saveScope}>{t('common.save')}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -136,10 +136,29 @@
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
_prevProviderId = form.provider_id;
|
||||
loadCollections();
|
||||
// Auto-select first available tracking/template config for this provider when creating
|
||||
if (editing === null) {
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
if (!form.default_tracking_config_id) {
|
||||
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||
if (first) form.default_tracking_config_id = first.id;
|
||||
}
|
||||
if (!form.default_template_config_id) {
|
||||
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||
if (first) form.default_template_config_id = first.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
// Auto-select first provider if any
|
||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
||||
}
|
||||
|
||||
async function edit(trk: Tracker) {
|
||||
form = {
|
||||
@@ -256,7 +275,7 @@
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const d = parseDate(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch (e) { console.warn('Date format error:', e); return ''; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
@@ -131,12 +132,17 @@
|
||||
}
|
||||
|
||||
function startDelete(provider: any) { confirmDelete = provider; }
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -280,6 +286,8 @@
|
||||
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
.health-dot {
|
||||
width: 10px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import type { WebhookPayloadLog } from '$lib/types';
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
return parseDate(iso).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, fetchAuth } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
@@ -60,15 +59,23 @@
|
||||
let backupFiles = $state<any[]>([]);
|
||||
let loadingFiles = $state(false);
|
||||
let confirmDeleteFile = $state('');
|
||||
let creatingBackup = $state(false);
|
||||
|
||||
// --- Pending restore state ---
|
||||
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
|
||||
let postRestoreModalOpen = $state(false);
|
||||
let restartingOverlay = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [settings, files] = await Promise.all([
|
||||
const [settings, files, p] = await Promise.all([
|
||||
api('/backup/scheduled'),
|
||||
api('/backup/files'),
|
||||
api('/backup/pending-restore'),
|
||||
]);
|
||||
scheduledSettings = settings;
|
||||
backupFiles = files;
|
||||
pending = p;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
@@ -77,6 +84,53 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function cancelPending() {
|
||||
try {
|
||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||
snackSuccess(t('backup.pendingCancelled'));
|
||||
pending = null;
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function applyAndRestart() {
|
||||
try {
|
||||
await api('/backup/apply-restart', { method: 'POST' });
|
||||
restartingOverlay = true;
|
||||
// Poll /health until the new instance is up
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const res = await fetch('/api/health');
|
||||
if (res.ok && Date.now() - startedAt > 2000) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch { /* still down */ }
|
||||
if (attempts < 120) setTimeout(poll, 1000);
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
} catch (err: any) {
|
||||
restartingOverlay = false;
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createManualBackup() {
|
||||
creatingBackup = true;
|
||||
try {
|
||||
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
||||
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
||||
snackSuccess(t('backup.manualCreated'));
|
||||
await refreshFiles();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
creatingBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
async function doExport() {
|
||||
if (exportSecrets === 'include') {
|
||||
@@ -120,16 +174,7 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch('/api/backup/validate', {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||
validationResult = await res.json();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
@@ -151,18 +196,15 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
|
||||
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
importResult = await res.json();
|
||||
snackSuccess(t('backup.importSuccess'));
|
||||
pending = importResult;
|
||||
snackSuccess(t('backup.restorePrepared'));
|
||||
postRestoreModalOpen = true;
|
||||
importFile = null;
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -256,6 +298,33 @@
|
||||
<Loading />
|
||||
{:else}
|
||||
<ErrorBanner message={error} />
|
||||
|
||||
{#if pending?.pending}
|
||||
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
|
||||
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
|
||||
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
|
||||
<MdiIcon name="mdiClockAlert" size={20} />
|
||||
</span>
|
||||
<div class="flex-1 min-w-[12rem] text-sm">
|
||||
<div class="font-medium">{t('backup.pendingTitle')}</div>
|
||||
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
|
||||
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if pending.supervised}
|
||||
<Button size="sm" onclick={applyAndRestart}>
|
||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||
</Button>
|
||||
{/if}
|
||||
<button onclick={cancelPending}
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Export Section -->
|
||||
@@ -502,9 +571,14 @@
|
||||
<MdiIcon name="mdiFolder" size={18} />
|
||||
{t('backup.savedFiles')}
|
||||
</h3>
|
||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||
<MdiIcon name="mdiRefresh" size={14} />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
|
||||
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
|
||||
</Button>
|
||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||
<MdiIcon name="mdiRefresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if backupFiles.length === 0}
|
||||
@@ -568,3 +642,69 @@
|
||||
onconfirm={() => deleteFile(confirmDeleteFile)}
|
||||
oncancel={() => confirmDeleteFile = ''}
|
||||
/>
|
||||
|
||||
<!-- Post-restore modal: Apply now or later -->
|
||||
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
||||
{#if postRestoreModalOpen && pending?.pending}
|
||||
<div class="post-restore-backdrop"
|
||||
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
|
||||
onclick={() => postRestoreModalOpen = false}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
||||
role="presentation">
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
|
||||
<MdiIcon name="mdiClockAlert" size={22} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
|
||||
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end flex-wrap">
|
||||
<button onclick={() => postRestoreModalOpen = false}
|
||||
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
{t('backup.applyLater')}
|
||||
</button>
|
||||
{#if pending.supervised}
|
||||
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
|
||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Restarting overlay -->
|
||||
{#if restartingOverlay}
|
||||
<div role="alert" aria-live="assertive"
|
||||
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
|
||||
<div class="text-center p-6" style="color: var(--color-foreground);">
|
||||
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
|
||||
<MdiIcon name="mdiRestart" size={40} />
|
||||
</div>
|
||||
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
|
||||
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.restart-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: restart-spin 1.2s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
@keyframes restart-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -114,7 +115,7 @@
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', bot_id: 0, bot_token: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
||||
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
||||
// Discord/Slack shared settings
|
||||
username: '',
|
||||
// ntfy shared settings
|
||||
@@ -193,6 +194,10 @@
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
formType = activeType || 'telegram';
|
||||
// Auto-select first available bot of the chosen type
|
||||
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
|
||||
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
|
||||
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
|
||||
editing = null;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
@@ -289,12 +294,15 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
async function remove(id: number) {
|
||||
try {
|
||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetDeleted'));
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
}
|
||||
@@ -529,3 +537,5 @@
|
||||
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
|
||||
oncancel={() => confirmDeleteReceiver = null}
|
||||
/>
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -208,7 +209,12 @@
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); }
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
|
||||
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||
refreshDateFormatPreview();
|
||||
}
|
||||
function edit(c: TemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
@@ -253,12 +259,17 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
@@ -455,6 +466,8 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -72,12 +73,17 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
@@ -257,6 +263,8 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -31,6 +31,13 @@
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
// Admin edit username/role
|
||||
let editUserId = $state<number | null>(null);
|
||||
let editUsername = $state('');
|
||||
let editRole = $state('user');
|
||||
let editMsg = $state('');
|
||||
let editSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
@@ -56,6 +63,20 @@
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
function openEditUser(user: any) {
|
||||
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||
}
|
||||
async function saveUserEdit(e: SubmitEvent) {
|
||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||
try {
|
||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||
editMsg = t('snack.userUpdated');
|
||||
editSuccess = true;
|
||||
snackSuccess(editMsg);
|
||||
await load();
|
||||
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
@@ -111,9 +132,10 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
@@ -144,5 +166,28 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Admin edit username/role modal -->
|
||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||
<div>
|
||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="edit-username" bind:value={editUsername} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="edit-role" bind:value={editRole}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if editMsg}
|
||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
Reference in New Issue
Block a user