fix(ui): keep trash counter in sync with trash changes

Reload the rail trash count after every operation that changes the
trash contents, not just navigation and empty-trash: moving photos to
trash (grid, panel, viewer), restoring, permanent delete and deleting
an album. Previously the counter stayed stale until the next route
change.
This commit is contained in:
iu
2026-10-12 12:58:18 +03:00
parent bf3641f9e0
commit 713c1ca6d3
11 changed files with 165 additions and 0 deletions
@@ -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;
@@ -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 */
@@ -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 {
@@ -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)];
@@ -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;
@@ -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 {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12
@@ -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 и данных.
@@ -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-клиент (корзины в нём нет).
@@ -0,0 +1,24 @@
# Spec Delta
## ADDED Requirements
### Requirement: Синхронизация счётчика корзины
Рейл SHALL обновлять число элементов корзины сразу после любого действия, меняющего
содержимое корзины, без перезагрузки страницы: помещения фото в корзину, восстановления,
окончательного удаления и удаления альбома с фото.
#### Scenario: Помещение фото в корзину
- **WHEN** пользователь удаляет одно или несколько фото
- **THEN** фото покидают сетку, а число элементов корзины в рейле увеличивается на их количество
#### Scenario: Восстановление из корзины
- **WHEN** пользователь восстанавливает фото из корзины
- **THEN** фото покидает список корзины, а число элементов корзины в рейле уменьшается на единицу
#### Scenario: Окончательное удаление
- **WHEN** пользователь окончательно удаляет фото из корзины
- **THEN** число элементов корзины в рейле уменьшается на единицу
#### Scenario: Удаление альбома
- **WHEN** пользователь удаляет альбом с фото
- **THEN** его фото попадают в корзину, а число элементов корзины в рейле увеличивается на их количество
@@ -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`.