diff --git a/frontend/src/lib/components/AlbumActionsDialogs.svelte b/frontend/src/lib/components/AlbumActionsDialogs.svelte index dabf0ac..7b2e352 100644 --- a/frontend/src/lib/components/AlbumActionsDialogs.svelte +++ b/frontend/src/lib/components/AlbumActionsDialogs.svelte @@ -6,6 +6,7 @@ import { portal } from '$lib/actions/portal'; import { albums } from '$lib/stores/albums.svelte'; import { albumActions } from '$lib/stores/album-actions.svelte'; + import { trash } from '$lib/stores/trash.svelte'; let title = $state(''); let busy = $state(false); @@ -48,6 +49,7 @@ try { await albums.destroy(a.id); albumActions.deleting = null; + await trash.load(); if ($page.params.id === a.id) await goto('/'); } catch (e) { error = (e as Error).message; diff --git a/frontend/src/lib/components/PhotoPanel.svelte b/frontend/src/lib/components/PhotoPanel.svelte index a4a03c2..94c5a41 100644 --- a/frontend/src/lib/components/PhotoPanel.svelte +++ b/frontend/src/lib/components/PhotoPanel.svelte @@ -8,6 +8,7 @@ import { auth } from '$lib/stores/auth.svelte'; import { albums } from '$lib/stores/albums.svelte'; import { transfer } from '$lib/stores/transfer.svelte'; + import { trash } from '$lib/stores/trash.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif'; import type { Photo, Preferences, Tag } from '$lib/types'; @@ -193,6 +194,7 @@ try { await photosApi.deleteBatch([photo.id]); pendingDelete = false; + await trash.load(); onDeleted?.(); } catch { /* ignore */ diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte index baa8b41..a8bd45e 100644 --- a/frontend/src/routes/(app)/albums/[id]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -13,6 +13,7 @@ import { density } from '$lib/stores/density.svelte'; import { selection } from '$lib/stores/selection.svelte'; import { photoUpdates } from '$lib/stores/photo-updates.svelte'; + import { trash } from '$lib/stores/trash.svelte'; import { viewerReturn } from '$lib/stores/viewer-return'; import { transfer } from '$lib/stores/transfer.svelte'; import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte'; @@ -295,6 +296,7 @@ await photosApi.deleteBatch(selection.ids); selection.clear(); pendingDelete = false; + await trash.load(); } catch (e) { error = (e as Error).message; } finally { diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte index 471d431..31fb6b2 100644 --- a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte @@ -7,6 +7,7 @@ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { photoUpdates } from '$lib/stores/photo-updates.svelte'; + import { trash } from '$lib/stores/trash.svelte'; import { viewerReturn } from '$lib/stores/viewer-return'; import type { Album, Photo } from '$lib/types'; @@ -79,6 +80,7 @@ try { await api(`/photos/${photo.id}`, { method: 'DELETE' }); pendingDelete = null; + await trash.load(); const remaining = photos.filter((p) => p.id !== photo.id); if (remaining.length > 0) { const next = remaining[Math.min(Math.max(index, 0), remaining.length - 1)]; diff --git a/frontend/src/routes/(app)/albums/[id]/settings/+page.svelte b/frontend/src/routes/(app)/albums/[id]/settings/+page.svelte index debf745..ee4ea34 100644 --- a/frontend/src/routes/(app)/albums/[id]/settings/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/settings/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import Icon from '$lib/components/Icon.svelte'; + import { trash } from '$lib/stores/trash.svelte'; import type { Album, ShareLink } from '$lib/types'; const albumId = $derived($page.params.id); @@ -73,6 +74,7 @@ if (!confirm('Удалить альбом вместе с фото?')) return; try { await api(`/albums/${albumId}`, { method: 'DELETE' }); + await trash.load(); goto('/'); } catch (e) { error = (e as Error).message; diff --git a/frontend/src/routes/(app)/trash/+page.svelte b/frontend/src/routes/(app)/trash/+page.svelte index a2cadcb..8b04241 100644 --- a/frontend/src/routes/(app)/trash/+page.svelte +++ b/frontend/src/routes/(app)/trash/+page.svelte @@ -47,6 +47,7 @@ try { await photosApi.restore(p.id); photos = photos.filter((x) => x.id !== p.id); + await trash.load(); } catch (e) { if (e instanceof ApiError && e.code === 'album_required') { restoring = p; @@ -76,6 +77,7 @@ await photosApi.restore(photo.id, id); photos = photos.filter((x) => x.id !== photo.id); restoring = null; + await trash.load(); } catch (e) { restoreError = (e as Error).message; } finally { @@ -90,6 +92,7 @@ await photosApi.permanentDelete(pending.id); photos = photos.filter((x) => x.id !== pending?.id); pending = null; + await trash.load(); } catch (e) { error = (e as Error).message; } finally { diff --git a/openspec/changes/fix-trash-counter-sync/.openspec.yaml b/openspec/changes/fix-trash-counter-sync/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/fix-trash-counter-sync/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/fix-trash-counter-sync/design.md b/openspec/changes/fix-trash-counter-sync/design.md new file mode 100644 index 0000000..8ea30b8 --- /dev/null +++ b/openspec/changes/fix-trash-counter-sync/design.md @@ -0,0 +1,61 @@ +# Design + +## Context + +См. `proposal.md` — Why. Существенное: + +- `TrashStore` (`frontend/src/lib/stores/trash.svelte.ts`) держит `count` и умеет `load()` + (запрос `GET /api/trash/count`) и `reset()`. +- `AlbumRail.svelte` показывает `trash.count` и вызывает `trash.load()` только в `$effect` + при смене `$page.url.pathname` и при аутентификации. +- `trash/+page.svelte` вызывает `trash.load()` только в `emptyAll`; `restore`, + `confirmRestore`, `remove` (окончательное удаление) обновляют лишь свой список. +- Удаление фото разбросано: `deleteBatch` в сетке и панели, прямой `api(...DELETE)` в + просмотре; удаление альбома — в `AlbumActionsDialogs` и `settings/+page.svelte`. +- Серверный `TrashCount` считает мягко удалённые фото, доступные пользователю, и корректен. + +## Goals / Non-Goals + +**Goals:** +- Счётчик корзины в рейле отражает актуальное состояние после любой операции, меняющей + содержимое корзины, без перезагрузки. +- Единообразный, предсказуемый способ обновления во всех точках. + +**Non-Goals:** +- Синхронизация счётчика между сессиями/устройствами (SSE-событий корзины нет). +- Изменения backend и Android. +- Оптимистичные локальные инкременты без сверки с сервером. + +## Decisions + +### 1. Обновлять счётчик из сервера (`trash.load()`), а не инкрементировать локально +После каждой успешной мутации вызываем `trash.load()`. Это даёт точное значение для всех +случаев, включая пакетное удаление и удаление альбома (где число фото заранее неизвестно +точно), и повторяет уже принятый в `emptyAll` паттерн. Альтернатива с оптимистичным +`+n`/`-1` требует знать точное число и расходится при частичных ошибках; альтернатива с +общим сигналом (как `photo-updates`) добавляет лишний слой ради одного значения. + +### 2. Вызывать обновление в каждой точке мутации, после успешного await +Обновление идёт после успешного ответа (в `try`, не в `finally`), чтобы при ошибке +счётчик не менялся. Точки: пакетное удаление (сетка), удаление из панели/просмотра, +удаление в просмотре, восстановление и окончательное удаление в корзине, удаление +альбома (диалог и настройки). Это устраняет исходный дефект и родственные рассинхроны +той же природы. + +### 3. Оставить асинхронность неблокирующей +`trash.load()` уже глотает ошибки и не мешает основному действию; вызов не await-им +блокирующе там, где это меняет UX (в остальных местах допустимо `await` для +детерминизма тестов). Достаточно, чтобы счётчик обновился к моменту отрисовки. + +## Risks / Trade-offs + +- [Лишний `GET /api/trash/count` на каждую мутацию] → незначительная нагрузка, сопоставимо + с текущим поведением при навигации и очистке корзины. +- [Гонка при параллельных мутациях] → последний ответ сервера побеждает; значение + остаётся корректным, т.к. берётся из источника истины. +- [Разброс точек вызова — риск пропустить новую в будущем] → спека фиксирует инвариант; + при добавлении новых операций с корзиной требование распространяется на них. + +## Migration Plan + +Не требуется: изменения только во фронтенде, без изменения API и данных. diff --git a/openspec/changes/fix-trash-counter-sync/proposal.md b/openspec/changes/fix-trash-counter-sync/proposal.md new file mode 100644 index 0000000..9f463d2 --- /dev/null +++ b/openspec/changes/fix-trash-counter-sync/proposal.md @@ -0,0 +1,42 @@ +# Proposal + +## Why + +Счётчик корзины в рейле альбомов (`trash.count`) перезагружается только при смене +маршрута и после «Очистить корзину». Любая другая операция, меняющая содержимое корзины, +не обновляет его: после удаления фото счётчик остаётся прежним, пока пользователь не +перейдёт в другой раздел. Это и есть сообщённый дефект — «при удалении фото на корзине не +меняется счётчик». + +## What Changes + +- Рейл SHALL обновлять число элементов корзины сразу после каждого действия, меняющего её + содержимое, без перезагрузки страницы: помещение фото в корзину (одиночное и пакетное), + восстановление, окончательное удаление, очистка корзины и удаление альбома (его фото + уходят в корзину). +- Изменения только во фронтенде: серверный `GET /api/trash/count` и семантика мягкого + удаления корректны и не меняются. + +## Capabilities + +### New Capabilities + +Нет. + +### Modified Capabilities + +- `ui-workspace`: добавляется требование синхронизации счётчика корзины в рейле с + операциями, меняющими содержимое корзины, без перезагрузки страницы. + +## Impact + +- Frontend: `frontend/src/lib/stores/trash.svelte.ts` (способ обновления счётчика), + `frontend/src/routes/(app)/albums/[id]/+page.svelte` (пакетное удаление), + `frontend/src/lib/components/PhotoPanel.svelte` (удаление из панели/просмотра), + `frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte` (удаление в + просмотре), `frontend/src/routes/(app)/trash/+page.svelte` (восстановление и + окончательное удаление), `frontend/src/lib/components/AlbumActionsDialogs.svelte` и + `frontend/src/routes/(app)/albums/[id]/settings/+page.svelte` (удаление альбома). +- Backend: не затрагивается. +- Вне объёма: синхронизация счётчика между сессиями/устройствами через SSE (событий корзины + сервер не публикует) и Android-клиент (корзины в нём нет). diff --git a/openspec/changes/fix-trash-counter-sync/specs/ui-workspace/spec.md b/openspec/changes/fix-trash-counter-sync/specs/ui-workspace/spec.md new file mode 100644 index 0000000..c79ee6b --- /dev/null +++ b/openspec/changes/fix-trash-counter-sync/specs/ui-workspace/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Синхронизация счётчика корзины +Рейл SHALL обновлять число элементов корзины сразу после любого действия, меняющего +содержимое корзины, без перезагрузки страницы: помещения фото в корзину, восстановления, +окончательного удаления и удаления альбома с фото. + +#### Scenario: Помещение фото в корзину +- **WHEN** пользователь удаляет одно или несколько фото +- **THEN** фото покидают сетку, а число элементов корзины в рейле увеличивается на их количество + +#### Scenario: Восстановление из корзины +- **WHEN** пользователь восстанавливает фото из корзины +- **THEN** фото покидает список корзины, а число элементов корзины в рейле уменьшается на единицу + +#### Scenario: Окончательное удаление +- **WHEN** пользователь окончательно удаляет фото из корзины +- **THEN** число элементов корзины в рейле уменьшается на единицу + +#### Scenario: Удаление альбома +- **WHEN** пользователь удаляет альбом с фото +- **THEN** его фото попадают в корзину, а число элементов корзины в рейле увеличивается на их количество diff --git a/openspec/changes/fix-trash-counter-sync/tasks.md b/openspec/changes/fix-trash-counter-sync/tasks.md new file mode 100644 index 0000000..8e7a825 --- /dev/null +++ b/openspec/changes/fix-trash-counter-sync/tasks.md @@ -0,0 +1,23 @@ +# Tasks + +## 1. Счётчик после удаления фото + +- [x] 1.1 Обновлять `trash.load()` после пакетного удаления в сетке (`routes/(app)/albums/[id]/+page.svelte`, `confirmMenuDelete`) — проверить вручную: удаление одного и нескольких фото увеличивает счётчик корзины в рейле без перехода между разделами +- [x] 1.2 Обновлять `trash.load()` после удаления из панели (`lib/components/PhotoPanel.svelte`, `confirmDelete`) и в просмотре (`routes/(app)/albums/[id]/photos/[photoId]/+page.svelte`, `confirmDelete`) — проверить оба сценария вручную + +## 2. Счётчик при удалении альбома + +- [x] 2.1 Обновлять `trash.load()` после удаления альбома (`lib/components/AlbumActionsDialogs.svelte` после `albums.destroy` и `routes/(app)/albums/[id]/settings/+page.svelte` после DELETE) — проверить вручную: удаление альбома с фото увеличивает счётчик на число фото + +## 3. Восстановление и окончательное удаление + +- [x] 3.1 В `routes/(app)/trash/+page.svelte` обновлять `trash.load()` после `restore`, `confirmRestore` и `remove` — проверить вручную: восстановление и окончательное удаление уменьшают счётчик корзины + +## 4. Проверка + +- [x] 4.1 `cd frontend && npm run check` проходит без ошибок +- [x] 4.2 Ручной чек-лист сквозного сценария: удаление фото (одно/несколько), удаление из просмотра, удаление альбома, восстановление, окончательное удаление, очистка корзины — счётчик в рейле всегда актуален без перезагрузки страницы + +## Workflow follow-up + +- Архивировать изменение после ревью и синхронизировать основную спеку `ui-workspace`.