chore(openspec): archive lightroom-workspace-ui and sync main specs
- synced delta specs into main specs: albums (+3 reqs, MODIFIED deleting album -> photos to trash), auth (+1), photos (+8), and created new ui-workspace spec (29 reqs) - moved the change to openspec/changes/archive/2026-10-10-lightroom-workspace-ui/
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-10
|
||||
@@ -0,0 +1,313 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. proposal.md — Why. Текущее состояние, которое формирует подход:
|
||||
|
||||
- `albums.cover_photo_id` уже существует (`migrations/000001_init.up.sql`), `PATCH /albums/:id`
|
||||
принимает `cover_photo_id`/`clear_cover` (`albums/handlers.go:55`), но список и `GET`
|
||||
отдают только id — URL миниатюры негде взять, поэтому карточки рисуют иконку-заглушку.
|
||||
- `photos.Service.attachURLs` уже пресигнит `ThumbKey`/`PreviewKey` (`photos/service.go:132`),
|
||||
а `albums.Service` держит `storage *storage.Client` — приём с presigned URL доступен и там.
|
||||
- Фоновая обработка загруженного фото безусловно зовёт `photos.Store.SetCover`
|
||||
(`photos/service.go:124`), перезаписывая выбор пользователя.
|
||||
- FK на `cover_photo_id` нет; удаление фото-обложки оставляет висячий id.
|
||||
- Фронтенд: один плоский `+layout.svelte` (топбар + `max-w-6xl` + центр), лайтбокс —
|
||||
оверлей, на `share/[token]` свой инлайн-лайтбокс. Темы: light/dark/neutral/system, дефолт
|
||||
`system` (`stores/theme.svelte.ts`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Постоянный каркас с рейлом и контекстной панелью, центр = текущий маршрут (Q).
|
||||
- Обложки доезжают до UI; явный выбор + разумный дефолт; корректное удаление.
|
||||
- Тёмная графитовая тема по умолчанию при сохранении остальных тем.
|
||||
- Просмотр фото как маршрут в каркасе, общий с публичной share-страницей.
|
||||
- Адаптивность до мобильного без потери каркаса.
|
||||
- Пер-пользовательские настройки раздела «Информация» (видимость атрибутов и подписи),
|
||||
хранимые в профиле.
|
||||
|
||||
**Non-Goals:**
|
||||
- Мультивыбор фото и массовые действия / bulk-удаление.
|
||||
- Замена роутинга на «чистые панели» без маршрутов (вариант P).
|
||||
- Новые внешние зависимости (иконки и токены вендорятся/расширяются).
|
||||
- Изменение доменных контрактов API (только добавление поля ответа и FK).
|
||||
- Порядок атрибутов и настройки на уровне альбома/фото — настройки глобальны для пользователя.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Каркас: группы маршрутов, центр = маршрут
|
||||
`+layout.svelte` разбивается на группы: `(app)` — каркас с топбаром, левым рейлом и правой
|
||||
панелью, и `(public)` — только токены. Центр `(app)` рендерит текущий маршрут; рейл живёт
|
||||
между навигациями.
|
||||
Альтернатива — один layout с условиями по `$page.route.id` (проще, но разрастается) или P
|
||||
(панели вместо маршрутов) — отклонена: ломает deeplink и историю.
|
||||
|
||||
### 2. Обложка: LEFT JOIN + presign (A)
|
||||
`albums.Store` добавляет `LEFT JOIN photos p ON p.id = a.cover_photo_id` и отдаёт
|
||||
`cover_thumb_key`; `albums.Service` пресигнит его в `Album.CoverThumbURL`. Один запрос и один
|
||||
presign на альбом; висячий id безопасно даёт `NULL`. Альтернативы: N+1 с `GET /photos/:id`
|
||||
(chatty) и выделенный thumb-эндпоинт (расходится с текущим паттерном presigned URL).
|
||||
|
||||
### 3. Автообложка: писать только если пусто
|
||||
`processVariants` вызывает `SetCover` только при `cover_photo_id IS NULL`. Выбор пользователя
|
||||
не слетает, дефолт появляется с первым фото. Альтернатива — дефолт на чтении (COALESCE) —
|
||||
отклонена в пользу простоты записи/чтения; осознанно принимаем, что в пачке побеждает
|
||||
фото, обработанное первым.
|
||||
|
||||
### 4. Целостность обложки: FK ON DELETE SET NULL (M)
|
||||
Миграция `000002_album_cover_fk`: сперва обнуляет уже висячие `cover_photo_id`, затем
|
||||
`ALTER TABLE albums ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id)
|
||||
ON DELETE SET NULL`. БД сама чинит любой путь удаления. Циклический FK `albums <-> photos`
|
||||
безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже.
|
||||
Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления).
|
||||
|
||||
### 5. Точка выбора обложки: правая панель (T1)
|
||||
Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`;
|
||||
всё под `roles.CanEditAlbum`. Действие «Сделать обложкой» в тулбаре просмотра и hover-оверлей
|
||||
в сетке (T3) не используются — шум и случайные клики.
|
||||
|
||||
### 6. Тема: сохранить темы, dark = графит и дефолт (α)
|
||||
Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию
|
||||
(`theme.current = 'dark'` при отсутствии сохранённого выбора). Добавляются аддитивные токены
|
||||
`--chrome` (топбар/рейл/панель) и `--canvas` (центр), чтобы «утопить» контент между панелями.
|
||||
`light`/`neutral`/`system` остаются. Спека `ui-iconography` не затрагивается.
|
||||
|
||||
### 7. Адаптив: drawer + sheet + горизонтальная лента (M1)
|
||||
Порог рабочего стола — `lg`/`md`. Ниже: рейл — off-canvas drawer по кнопке, правая панель —
|
||||
сворачиваемая секция/bottom-sheet, filmstrip — горизонтальный скролл. Публичные экраны
|
||||
получают ту же мобильную логику просмотра без рейла/панели.
|
||||
|
||||
### 8. Плотность сетки, без выбора (D)
|
||||
Регулятор размера миниатюр в тулбаре сетки управляет `grid-template-columns` (пресеты или
|
||||
`minmax`), значение хранится в `localStorage`. Мультивыбор/bulk — вне объёма.
|
||||
|
||||
### 9. Просмотр как маршрут (L2)
|
||||
Новый маршрут `albums/[id]/photos/[photoId]` рендерит общий компонент `Viewer` (крупное фото,
|
||||
filmstrip, панель метаданных/комментариев, тулбар). `share/[token]` переиспользует `Viewer`
|
||||
без рейла/панели. Навигация синхронизирует маршрут и filmstrip; клавиши ←/→/Esc.
|
||||
|
||||
### 10. Загрузка из тулбара (U1)
|
||||
Иконочная кнопка загрузки (роль-gated) открывает файловый диалог; `dragover` на весь альбом
|
||||
показывает полноэкранный оверлей; пустой альбом показывает центральную зону-подсказку.
|
||||
Прогресс — компактная полоса/тост вместо крупного блока.
|
||||
|
||||
### 11. Создание альбома через модалку
|
||||
Кнопка `+` с хинтом (в рейле и/или топбаре) открывает модалку с полем названия; `POST
|
||||
/albums`, созданный альбом становится активным. Инлайн-форма удаляется.
|
||||
|
||||
### 12. Переименование фото: `PATCH /photos/:id`
|
||||
Имя файла меняется через новый эндпоинт; права — как у удаления (`CanDeleteAnyPhoto` или
|
||||
автор своего фото). Пустое имя отклоняется, длина ограничивается 255 символами, изменение
|
||||
транслируется событием `photo.updated`. UI — инлайн-редактор в правой панели. Альтернатива —
|
||||
редактировать `caption` вместо имени — отклонена: пользователь просил имя файла.
|
||||
|
||||
### 13. Просмотр: полный экран и зум
|
||||
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
|
||||
`fullscreenchange`. `f`/кнопка переключают именно полноэкранный режим вьюера: если активен
|
||||
полноэкранный режим приложения, `f` входит в просмотр фото вложенным режимом и не выключает
|
||||
режим приложения. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
|
||||
всплывают у соответствующих кромок без таймеров — показываются и скрываются сразу по позиции
|
||||
курсора (панели — по касанию края; стрелки — при курсоре в 1/6 левой/правой области, только
|
||||
внутри границ вьюера, поэтому при уходе в боковые панели они скрыты; в зуме стрелки скрыты
|
||||
полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
|
||||
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
|
||||
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
|
||||
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
|
||||
перетаскиванием и скроллом. Хоткеи игнорируются при фокусе в поле ввода; зум сбрасывается при
|
||||
смене фото. Альтернатива — только `object-fit` без ручного масштаба — не даёт 1:1 и
|
||||
панорамирования.
|
||||
|
||||
### 14. Панели: ресайз и сворачиваемые разделы
|
||||
Ширина рейла и панели хранится в `localStorage`, меняется перетаскиванием разделителя
|
||||
(и стрелками с клавиатуры), ширина подаётся CSS-переменной на десктопных брейкпоинтах.
|
||||
Разделы панели — компонент `PanelSection` со сворачиванием и сохранением состояния.
|
||||
Альтернатива — фиксированные ширины и всегда развёрнутые секции — отклонена: не отвечает
|
||||
запросу.
|
||||
|
||||
### 15. Лимит загрузки
|
||||
Общий лимит тела запроса поднят до 100 МБ; при `http.MaxBytesError` возвращается
|
||||
`413 file_too_large` с понятным сообщением вместо маскировки под ошибку разбора multipart.
|
||||
Клиент заранее проверяет размер каждого файла и суммарный.
|
||||
|
||||
### 16. Счётчики альбома и событие обновления фото
|
||||
`photos_count`/`members_count` считаются подзапросами в тех же `ListForUser`/`GetByID`, чтобы
|
||||
панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома
|
||||
обновляет запись в списке.
|
||||
|
||||
### 17. Настройки информации: явный список видимых в профиле пользователя
|
||||
Настройки раздела «Информация» хранятся в профиле как явный список видимых атрибутов:
|
||||
`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида
|
||||
`{ "visible": ["Make", "Model", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие
|
||||
секции `photo_info` означает базовый набор по умолчанию (камера, объектив, выдержка,
|
||||
диафрагма, ISO, фокусное расстояние) — не «все атрибуты». Список `visible` хранится явно,
|
||||
поэтому различимо состояние «ничего не выбрано»; новые и незнакомые теги не «теряются» (их
|
||||
можно включить в диалоге). `GET /auth/me` отдаёт `preferences` вместе с пользователем;
|
||||
сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной валидацией:
|
||||
строки и массивы строк, ограничение длины подписи/ключа и числа записей). Пустой объект
|
||||
(`{}`) удаляет настройки, возвращая базовый набор. Настройки не входят в `PublicUser`
|
||||
(приватны). UI: `PanelSection` получает слот `actions` под шестерёнку; модалка
|
||||
`PhotoInfoSettingsDialog` строится из объединения известного каталога подписей и ключей
|
||||
текущего фото и даёт действия «отметить все»/«снять все» и «Сбросить»; список в панели
|
||||
фильтруется по сохранённым видимым атрибутам, а остальные сворачиваются под кнопку «…»
|
||||
(раскрытие — временное, для текущего фото). Альтернатива с оверрайдами `hidden` (пусто = все
|
||||
видимы) отклонена: не даёт базовый набор по умолчанию.
|
||||
|
||||
### 18. Метаданные фото: устойчивый парсер и хранение в jsonb
|
||||
EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`)
|
||||
как плоская карта «тег → значение» всех распознаваемых библиотекой тегов. Парсер —
|
||||
`github.com/dsoprea/go-exif/v3`: он переживает нестандартные и битые блоки EXIF, на которых
|
||||
падал прежний `rwcarlsen/goexif` (реальные снимки HUAWEI давали zero-length sub-IFD и пустой
|
||||
результат). Числовые значения нормализуются (дроби → число); дата съёмки и GPS дополнительно
|
||||
пишутся в колонки `taken_at`, `latitude`/`longitude`. На клиенте теги показываются с русскими
|
||||
подписями и переводом enum. Для ранее загруженных фото предусмотрена разовая команда
|
||||
`cmd/backfill-exif`, которая тянет оригиналы из S3 (`storage.Get`) и заполняет
|
||||
`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена:
|
||||
пользователю нужны все атрибуты.
|
||||
|
||||
### 19. Выбор фото в сетке через общий стор
|
||||
Выбор фото в сетке и контекстная панель живут в разных местах дерева (страница маршрута и слот
|
||||
`AppShell`), поэтому выбранное фото хранится в общем сторе `selection` (`albumId` + `photo`).
|
||||
Одиночный клик в `PhotoGrid` записывает выбор (рамка `ring` + `aria-pressed`), и панель
|
||||
показывает сведения о выбранном фото; двойной клик открывает маршрут просмотра. Панель
|
||||
использует `panelPhoto = photoId ? routePhoto : selectedPhoto`, поэтому в просмотре приоритет у
|
||||
маршрута, а в альбоме — у выбора; выбор сбрасывается при смене альбома. Альтернатива — хранить
|
||||
выбор в URL (query) — избыточно для локального выделения.
|
||||
|
||||
### 20. Корзина: мягкое удаление и подсчёт ссылок
|
||||
Удаление фото — мягкое (`photos.deleted_at`, миграция `000005_photo_trash`): списки и просмотр
|
||||
исключают такие фото, `GET /trash` отдаёт их по альбомам пользователя (владелец или участник).
|
||||
Восстановление снимает `deleted_at`. Окончательное удаление удаляет запись, и только если
|
||||
после этого `storage_key` больше нигде не встречается (копии в других альбомах разделяют
|
||||
объект), удаляются объекты S3 (оригинал, thumb, preview). Альтернатива — удалять S3 сразу —
|
||||
порвала бы копии.
|
||||
|
||||
### 21. Перенос и копирование — только метаданные
|
||||
Перенос меняет `photos.album_id`; копирование создаёт новую запись с тем же `storage_key`,
|
||||
thumb/preview и метаданными (клон). Объекты S3 не дублируются, поэтому «ссылки в любых
|
||||
альбомах» — это строки photos с общим `storage_key`. Права: удаление в источнике (автор или
|
||||
owner/admin) и загрузка в цель (member+).
|
||||
|
||||
### 22. Мультивыбор и трансфер через общие сторы
|
||||
Выбор — стор `selection` (набор фото + anchor); Ctrl/Cmd — toggle, Shift — диапазон по
|
||||
индексам текущей сетки. Действия инициируют диалог: стор `transfer` хранит ids, необязательный
|
||||
целевой альбом и действие; глобальный `TransferDialog` (портал) выбирает альбом и
|
||||
перенос/копию. Перетаскивание с сетки на альбом рейла передаёт ids через `dataTransfer` типа
|
||||
`application/x-galo-photos`; дроп-цель — элементы рейла. Обработчик загрузки показывает оверлей
|
||||
только для типа `Files`, поэтому при переносе фото зона загрузки не появляется.
|
||||
|
||||
### 23. Рейл: вход в корзину и скрытие панели
|
||||
В рейле альбомов последним элементом списка добавлена «Корзина» (иконка корзины), ведущая на
|
||||
маршрут `/trash` со списком удалённых фото и действиями «Вернуть»/«Удалить навсегда». Пустая
|
||||
правая панель на обзоре скрыта: `AppShell` получает `panelVisible`, который layout выключает на
|
||||
корневом маршруте.
|
||||
|
||||
### 24. Компактный верхний хром
|
||||
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
|
||||
минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок,
|
||||
иконки `size-4`, компактный переключатель темы. Верхняя панель просмотра — прозрачный оверлей
|
||||
(`absolute`, без фона и рамки, `pointer-events-none` с активными кнопками), поэтому не занимает
|
||||
место в раскладке и увеличивает область фото. Функциональность (меню, панель, темы, полный
|
||||
экран, навигация, скачивание, удаление) сохраняется.
|
||||
|
||||
### 25. Действия в режиме просмотра — в правой панели
|
||||
Скачивание и удаление убраны из верхней панели просмотра и перенесены в правую панель, в раздел
|
||||
«Действия», вместе с «Переместить» и «Копировать». Скачивание — ссылка на presigned оригинал;
|
||||
перенос/копирование — через `transfer`/`TransferDialog`; удаление инициируется стором
|
||||
`viewerActions`, а обрабатывает его страница просмотра (та же логика перехода к соседнему фото
|
||||
после удаления).
|
||||
|
||||
### 26. Единая панель фото
|
||||
Содержимое правой панели для одного фото (имя файла, «Действия», «Информация», «Комментарии»)
|
||||
вынесено в общий компонент `PhotoPanel`, который переиспользуется и в режиме просмотра
|
||||
(`viewer=true`), и при одиночном выборе фото в сетке — это одна и та же панель, а не копия.
|
||||
Отличие только в наборе действий: в просмотре нет «Открыть», а удаление идёт через стор
|
||||
`viewerActions` (обрабатывает страница просмотра с переходом к соседнему фото); при выборе в
|
||||
сетке удаление выполняет сам компонент и очищает выбор. Мультивыбор (>1) показывает только
|
||||
раздел «Действия».
|
||||
|
||||
### 27. Контекстное меню фото
|
||||
Правый клик по фото в сетке и по изображению в просмотре открывает контекстное меню с теми же
|
||||
пунктами, что и раздел «Действия». Меню — общий стор `contextMenu` (`x`, `y`, items) и глобальный
|
||||
компонент `ContextMenu` (портал), закрывается по клику вне, `Escape`, скроллу и ресайзу.
|
||||
Пункты формируются на местах: в сетке — «Открыть (просмотр)», «Скачать», «Переместить»,
|
||||
«Копировать», «Удалить» (для мультивыбора без «Скачать»); в просмотре — без «Открыть».
|
||||
|
||||
### 28. Удаление альбома и восстановление фото
|
||||
Удаление альбома не должно терять файлы: перед удалением альбома его фото мягко удаляются
|
||||
(`deleted_at`), а FK `photos.album_id` меняется на `ON DELETE SET NULL` (колонка nullable,
|
||||
миграция `000006`) — после удаления альбома фото остаются строками с `album_id = NULL` в
|
||||
корзине. Объекты S3 не удаляются (каскадный `DeletePrefix` убран: он ломал бы копии в других
|
||||
альбомах). `GET /trash` включает такие «без альбома» фото загрузчика. Восстановление с
|
||||
указанием целевого альбома (`POST /photos/:id/restore {album_id}`) переносит фото туда; без
|
||||
цели и без альбома возвращается ошибка `album_required`, и клиент предлагает выбрать альбом или
|
||||
создать новый.
|
||||
|
||||
### 29. Контекстное меню альбома и счётчики в рейле
|
||||
Правый клик по альбому (в рейле и на обзоре) открывает меню «Переименовать»/«Удалить» через
|
||||
общий стор `contextMenu`; диалоги переименования и подтверждения удаления — глобальный
|
||||
`AlbumActionsDialogs` (стор `albumActions`). В рейле у каждого альбома показано число фото
|
||||
(`album.photos_count`), у корзины — число удалённых фото (лёгкий `GET /trash/count`, стор
|
||||
`trash`).
|
||||
|
||||
### 30. Метки фото (область — пользователь)
|
||||
Метки принадлежат пользователю: таблицы `tags` (`user_id`, уникальность по `lower(name)`) и
|
||||
`photo_tags` (миграция `000007`). Фото отдаёт метки текущего пользователя (подзапрос
|
||||
`json_agg` в `photoColumns` по `$2`-viewer). Эндпоинты: `GET /tags?q=` (поиск по меткам
|
||||
пользователя), `POST /photos/:id/tags {name}` (создаёт метку при необходимости и привязывает,
|
||||
возвращает обновлённое фото), `DELETE /photos/:id/tags/:tag_id`. UI — раздел «Метки» в
|
||||
`PhotoPanel`: чипы с удалением и поле с автодополнением (поиск + «Создать «…»»). Права: метку
|
||||
можно ставить на фото альбома, доступного пользователю.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||
при необходимости кэшировать URL на короткий TTL.
|
||||
- [Presigned URL обложки истекает при долго открытой странице] → URL обновляется при загрузке
|
||||
списка; при ошибке загрузки картинки показывать плейсхолдер.
|
||||
- [Циклический FK albums↔photos] → миграция обнуляет висячие id до создания constraint;
|
||||
каскадное удаление альбома обрабатывается БД (SET NULL по уже удаляемой строке безвреден).
|
||||
- [Реструктуризация `+layout` задевает все маршруты] → выносить каркас инкрементально, группы
|
||||
`(app)`/`(public)` в одном шаге, публичные страницы регрессионно проверяем.
|
||||
- [Добавление токенов темы задевает все экраны] → токены аддитивны, старые классы продолжают
|
||||
работать; проверка триад тем.
|
||||
- [Пачка загрузок: обложкой становится первое обработанное фото] → принято; дальнейшие
|
||||
загрузки обложку не трогают, пользователь может задать явно.
|
||||
- [Дополнительное состояние в маршруте просмотра (filmstrip, клавиатура)] → единый компонент
|
||||
`Viewer` с props и общим источником фото.
|
||||
- [Зум/панорамирование конфликтует с навигацией и прокруткой] → при увеличении отключаем
|
||||
стрелки и свайп; колесо над областью фото перехватывается с `preventDefault`.
|
||||
- [Глобальные хоткеи при вводе текста] → игнорируем `f`/`Space`/стрелки, когда фокус в
|
||||
`input`/`textarea`/`contenteditable`.
|
||||
- [Крупные файлы утяжеляют память] → лимит 100 МБ и чтение через `LimitReader`; превышение
|
||||
даёт понятную 413.
|
||||
- [Сохранённые ширина/состояние панелей] → значения ограничиваются допустимым диапазоном.
|
||||
- [Настройки в профиле: рост jsonb и приватность] → `preferences` не входят в `PublicUser`;
|
||||
серверная валидация ограничивает структуру, длину подписей и число скрытых ключей.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Миграция `000002_album_cover_fk`: `UPDATE albums SET cover_photo_id = NULL WHERE
|
||||
cover_photo_id IS NOT NULL AND cover_photo_id NOT IN (SELECT id FROM photos);` затем
|
||||
`ADD CONSTRAINT ... ON DELETE SET NULL`. Down — `DROP CONSTRAINT`.
|
||||
2. Миграция `000003_photo_exif`: `ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT
|
||||
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
|
||||
3. Миграция `000004_user_preferences`: `ALTER TABLE users ADD COLUMN preferences jsonb NOT
|
||||
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
|
||||
4. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
|
||||
(LEFT JOIN + счётчики), `albums/service.go` (enrich + presign), `photos` (автообложка только
|
||||
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`; извлечение EXIF в
|
||||
`photos.exif` и команда `cmd/backfill-exif`), `models.User` (+`Preferences`), `auth`
|
||||
(`GetUserByID` c `preferences`, `Me` отдаёт настройки, `PATCH /auth/me/preferences` с
|
||||
валидацией).
|
||||
5. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
|
||||
разделами и EXIF, модалки, `Viewer` с зумом), слот действий и модалка настроек атрибутов,
|
||||
стор настроек, `types.ts`.
|
||||
6. Откат: снять constraint и колонки `photos.exif`/`users.preferences` (down-миграции);
|
||||
фронтенд откатывается независимо, поля ответа игнорируются старым клиентом.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не
|
||||
меняет.
|
||||
- Хинты: нативный `title` или кастомный тултип — косметика, спеки не меняет.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Сейчас интерфейс — это центрированный «маркетинговый» лейаут: список альбомов с
|
||||
инлайн-формой создания, серая заглушка вместо обложек, отдельные страницы «Участники» и
|
||||
«Настройки», лайтбокс-оверлей. Обложки в бэкенде уже есть (`albums.cover_photo_id`,
|
||||
`PATCH /albums/:id`), но до UI не доходят. Пользователь хочет полноценный «рабочий стол»
|
||||
в духе Adobe Lightroom: постоянный рейл альбомов, контекстная панель, плотная сетка,
|
||||
режим просмотра как часть рабочего стола, обложки с явным выбором и единый тёмный
|
||||
графитовый хром.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Рабочий стол (шелл).** Группа `(app)` с постоянным левым рейлом альбомов, топбаром и
|
||||
контекстной правой панелью. Публичные маршруты (`/login`, `/invites/[token]`,
|
||||
`/share/[token]`) остаются вне шелла, но в тех же токенах.
|
||||
- **Создание альбома.** Инлайн-форма заменяется иконочной кнопкой с хинтом, открывающей
|
||||
модалку ввода названия.
|
||||
- **Обложки.** `Album` получает `cover_thumb_url`; карточки и рейл показывают реальную
|
||||
миниатюру. Выбор обложки: пикер в правой панели (действие «сделать обложкой» в
|
||||
просмотре не используется). Автообложка пишется только когда обложка ещё не задана; FK
|
||||
`albums.cover_photo_id -> photos(id) ON DELETE SET NULL` чинит висячие ссылки.
|
||||
- **Просмотр как маршрут.** `Lightbox`-оверлей заменяется маршрутом
|
||||
`/albums/:id/photos/:photoId` внутри шелла: крупное фото, filmstrip снизу, панель
|
||||
метаданных/комментариев. `share/[token]` переиспользует тот же компонент.
|
||||
- **Плотность сетки.** Слайдер размера миниатюр в тулбаре (состояние в localStorage).
|
||||
Мультивыбор и массовые действия — вне объёма.
|
||||
- **Загрузка.** Крупный dropzone заменяется кнопкой в тулбаре, полноэкранным
|
||||
drag-overlay и dropzone-намёком только в пустом альбоме. Лимит — 100 МБ с понятной
|
||||
ошибкой при превышении.
|
||||
- **Переименование фото.** `PATCH /photos/:id` меняет `original_filename`; инлайн-редактор
|
||||
в правой панели, доступный автору фото и owner/admin.
|
||||
- **Метаданные фото (EXIF).** При загрузке система извлекает все распознаваемые EXIF-атрибуты
|
||||
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
|
||||
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
|
||||
фото есть команда бэкфилла метаданных из оригиналов.
|
||||
- **Метки фото.** Пользователь присваивает фото свои метки: создаёт новые и выбирает из списка
|
||||
с поиском; метки показываются и снимаются в разделе «Метки» панели фото.
|
||||
- **Контекстные меню.** Правый клик по фото (в сетке и в просмотре) открывает меню с пунктами
|
||||
раздела «Действия»; правый клик по альбому (в рейле и на обзоре) — меню «Переименовать»/
|
||||
«Удалить».
|
||||
- **Удаление альбома.** Удаление альбома помещает его фото в корзину, не удаляя объекты
|
||||
хранилища; при восстановлении фото без альбома предлагается выбрать существующий альбом или
|
||||
создать новый.
|
||||
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
|
||||
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
|
||||
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
|
||||
действия просмотра (скачать, удалить, перенести, копировать) — в правой панели, в разделе
|
||||
«Действия»; удаление с модальным подтверждением; возврат к обзору.
|
||||
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
||||
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
||||
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
|
||||
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
|
||||
модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять
|
||||
все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…»
|
||||
(клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются
|
||||
ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет
|
||||
настройки из профиля.
|
||||
- **Мультивыбор и операции.** Ctrl/Shift-выбор нескольких фото в сетке; в правой панели —
|
||||
раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»;
|
||||
перетаскивание выделенных фото на альбом в рейле открывает диалог перенос/копирование.
|
||||
Перенос и копирование меняют только метаданные (объекты S3 не трогаются). Любое удаление
|
||||
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
|
||||
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
|
||||
обзоре убрана пустая правая панель.
|
||||
- **Счётчики.** В ответе альбома — число фото и участников; в рейле у каждого альбома показано
|
||||
число фото, у корзины — число удалённых фото.
|
||||
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
|
||||
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
|
||||
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
|
||||
Верхняя панель приложения и верхняя панель режима просмотра — компактные (минимум высоты).
|
||||
- **Адаптив.** На узких экранах рейл становится off-canvas drawer, правая панель —
|
||||
bottom-sheet, filmstrip — горизонтальным скроллом.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `ui-workspace`: рабочий стол приложения — шелл, рейл альбомов, контекстная панель,
|
||||
режим просмотра как маршрут, адаптивное поведение и тёмный хром.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `albums`: отображение обложки, счётчики фото/участников, явный выбор обложки и удаление в
|
||||
корзину.
|
||||
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
|
||||
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование, корзина и метки.
|
||||
- `auth`: хранение пользовательских настроек отображения информации в профиле.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Backend:** миграция FK `albums.cover_photo_id -> photos(id) ON DELETE SET NULL`;
|
||||
`internal/albums/store.go` (LEFT JOIN `photos` → `cover_thumb_key` и счётчики
|
||||
`photos_count`/`members_count` в `ListForUser`/`GetByID`); `internal/albums/service.go`
|
||||
(presign → `CoverThumbURL`, обогащение списка); `internal/models` (`Album.CoverThumbURL`,
|
||||
`PhotosCount`, `MembersCount`); `internal/photos` (автообложка только при
|
||||
`cover_photo_id IS NULL`; `PATCH /photos/:id` для переименования; лимит загрузки 100 МБ с
|
||||
`413 file_too_large`; событие `photo.updated`; извлечение EXIF через
|
||||
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
|
||||
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
|
||||
`models.User.Preferences`, `internal/auth` (`Me` отдаёт настройки,
|
||||
`PATCH /auth/me/preferences` с валидацией); миграции `000005_photo_trash` (мягкое удаление),
|
||||
`000006_photo_album_nullable` (FK `photos.album_id` → `ON DELETE SET NULL`, удаление альбома
|
||||
в корзину), `000007_photo_tags` (метки); `GET /trash`/`GET /trash/count`,
|
||||
`POST /photos/:id/restore` (c `album_id`), `DELETE /photos/:id/permanent`,
|
||||
`POST /photo-batch/{delete,move,copy}`, `GET /tags`, `POST/DELETE /photos/:id/tags`.
|
||||
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
|
||||
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
|
||||
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
|
||||
`Viewer` с полноэкранным режимом и зумом, общий стор альбомов, контрол плотности; новый
|
||||
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
|
||||
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
|
||||
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
|
||||
`photos_count`, `preferences`, `tags`); общие компоненты `PhotoPanel`, `ContextMenu`,
|
||||
`TransferDialog`, `AlbumActionsDialogs`; маршрут `/trash`; сторы `selection`, `transfer`,
|
||||
`contextMenu`, `albumActions`, `trash`, `viewerActions`; новые иконки в реестре.
|
||||
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
|
||||
затрагивается — иконки по-прежнему наследуют цвет темы.
|
||||
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
|
||||
изменений API нет.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отображение обложки альбома
|
||||
Данные альбома SHALL включать URL миниатюры обложки, когда обложка задана, чтобы клиенты
|
||||
могли показать её в списке, рейле и карточке.
|
||||
|
||||
#### Scenario: Обложка задана
|
||||
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
|
||||
- **THEN** ответ содержит URL миниатюры обложки
|
||||
|
||||
#### Scenario: Обложка отсутствует
|
||||
- **WHEN** у альбома нет обложки или фото обложки удалено
|
||||
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
|
||||
|
||||
### Requirement: Выбор обложки
|
||||
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
|
||||
обложку; после сброса обложка определяется заново по умолчанию.
|
||||
|
||||
#### Scenario: Назначение обложки
|
||||
- **WHEN** владелец выбирает фото альбома как обложку
|
||||
- **THEN** система сохраняет выбор и отражает его в списке и карточках
|
||||
|
||||
#### Scenario: Сброс обложки
|
||||
- **WHEN** админ сбрасывает обложку
|
||||
- **THEN** система удаляет явный выбор, и обложка возвращается к значению по умолчанию
|
||||
|
||||
#### Scenario: Нет прав на смену обложки
|
||||
- **WHEN** участник без права редактирования пытается сменить обложку
|
||||
- **THEN** система отклоняет запрос
|
||||
|
||||
### Requirement: Счётчики альбома
|
||||
Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли
|
||||
показать их без отдельных запросов.
|
||||
|
||||
#### Scenario: Счётчики в ответе
|
||||
- **WHEN** клиент запрашивает список альбомов или альбом по id
|
||||
- **THEN** ответ содержит число фото и число участников альбома
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Удаление альбома
|
||||
Владелец SHALL иметь возможность удалить альбом; фото альбома SHALL помещаться в корзину без
|
||||
удаления объектов хранилища, а участники и share-ссылки SHALL удаляться вместе с альбомом.
|
||||
|
||||
#### Scenario: Удаление альбома
|
||||
- **WHEN** владелец удаляет альбом
|
||||
- **THEN** фото альбома попадают в корзину без привязки к альбому и файлы сохраняются, участники и share-ссылки удаляются
|
||||
@@ -0,0 +1,24 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Хранение пользовательских настроек интерфейса
|
||||
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при
|
||||
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
|
||||
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
|
||||
|
||||
#### Scenario: Получение настроек
|
||||
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
|
||||
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию)
|
||||
|
||||
#### Scenario: Сохранение настроек
|
||||
- **WHEN** пользователь сохраняет настройки отображения
|
||||
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
|
||||
|
||||
#### Scenario: Изоляция настроек
|
||||
- **WHEN** другой пользователь запрашивает свой профиль
|
||||
- **THEN** он не получает настройки первого пользователя
|
||||
|
||||
#### Scenario: Некорректные настройки
|
||||
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
|
||||
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
|
||||
@@ -0,0 +1,118 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Обложка по умолчанию
|
||||
При обработке загруженного фото система SHALL назначать его обложкой альбома только если
|
||||
обложка ещё не задана.
|
||||
|
||||
#### Scenario: Первое фото
|
||||
- **WHEN** фото обработано, и у альбома нет обложки
|
||||
- **THEN** система назначает это фото обложкой
|
||||
|
||||
#### Scenario: Обложка уже задана
|
||||
- **WHEN** фото обработано, а обложка уже задана
|
||||
- **THEN** система не меняет обложку
|
||||
|
||||
### Requirement: Переименование фото
|
||||
Автор фото, владелец и админ альбома SHALL иметь возможность изменить имя файла фото;
|
||||
остальные SHALL получать отказ, а пустое имя SHALL отклоняться.
|
||||
|
||||
#### Scenario: Переименование автором
|
||||
- **WHEN** автор меняет имя файла своего фото на непустое
|
||||
- **THEN** система сохраняет новое имя и транслирует изменение подписчикам альбома
|
||||
|
||||
#### Scenario: Пустое имя
|
||||
- **WHEN** пользователь подтверждает пустое имя файла
|
||||
- **THEN** система отклоняет запрос
|
||||
|
||||
#### Scenario: Нет прав
|
||||
- **WHEN** участник без прав переименовывает чужое фото
|
||||
- **THEN** система отклоняет запрос
|
||||
|
||||
### Requirement: Ограничение размера загрузки
|
||||
Система SHALL ограничивать размер загружаемых файлов и SHALL отвечать понятной ошибкой при
|
||||
превышении лимита, а не ошибкой разбора формы.
|
||||
|
||||
#### Scenario: Превышение лимита
|
||||
- **WHEN** загружаемый файл превышает максимальный размер
|
||||
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
|
||||
|
||||
#### Scenario: Файл в пределах лимита
|
||||
- **WHEN** файл не превышает лимит
|
||||
- **THEN** система принимает multipart-загрузку и обрабатывает фото
|
||||
|
||||
### Requirement: Метаданные фото (EXIF)
|
||||
При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их
|
||||
вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор.
|
||||
|
||||
#### Scenario: Фото с метаданными
|
||||
- **WHEN** загружается фото с EXIF
|
||||
- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.)
|
||||
|
||||
#### Scenario: Фото без метаданных
|
||||
- **WHEN** загружается фото без читаемых EXIF
|
||||
- **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно
|
||||
|
||||
### Requirement: Перенос и копирование фото между альбомами
|
||||
Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь
|
||||
возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только
|
||||
метаданные и SHALL не изменять объекты хранилища.
|
||||
|
||||
#### Scenario: Перенос
|
||||
- **WHEN** пользователь переносит фото в другой альбом
|
||||
- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются
|
||||
|
||||
#### Scenario: Копирование
|
||||
- **WHEN** пользователь копирует фото в другой альбом
|
||||
- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3
|
||||
|
||||
### Requirement: Корзина фото
|
||||
Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать
|
||||
фото в его альбом. Окончательное удаление SHALL удалять запись, а объекты хранилища SHALL
|
||||
удаляться только когда на них не остаётся ссылок ни в одном альбоме.
|
||||
|
||||
#### Scenario: Удаление в корзину
|
||||
- **WHEN** пользователь удаляет фото
|
||||
- **THEN** фото пропадает из альбома и появляется в корзине
|
||||
|
||||
#### Scenario: Восстановление
|
||||
- **WHEN** пользователь восстанавливает фото из корзины
|
||||
- **THEN** фото возвращается в свой альбом
|
||||
|
||||
#### Scenario: Окончательное удаление без ссылок
|
||||
- **WHEN** пользователь удаляет фото из корзины навсегда, и на те же объекты нет ссылок в других альбомах
|
||||
- **THEN** запись и объекты хранилища удаляются
|
||||
|
||||
#### Scenario: Окончательное удаление при наличии ссылок
|
||||
- **WHEN** на объект хранилища ссылается копия в другом альбоме
|
||||
- **THEN** запись удаляется, а объект хранилища сохраняется
|
||||
|
||||
### Requirement: Удаление альбома и восстановление фото без альбома
|
||||
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
|
||||
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
|
||||
альбом или создать новый.
|
||||
|
||||
#### Scenario: Удаление альбома
|
||||
- **WHEN** владелец удаляет альбом
|
||||
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
|
||||
|
||||
#### Scenario: Восстановление без альбома
|
||||
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
|
||||
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
|
||||
|
||||
### Requirement: Метки фото
|
||||
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
|
||||
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
|
||||
|
||||
#### Scenario: Создание метки
|
||||
- **WHEN** пользователь вводит новую метку и подтверждает
|
||||
- **THEN** метка создаётся и привязывается к фото
|
||||
|
||||
#### Scenario: Выбор из списка с поиском
|
||||
- **WHEN** пользователь вводит часть названия метки
|
||||
- **THEN** показываются подходящие метки пользователя для выбора
|
||||
|
||||
#### Scenario: Снятие метки
|
||||
- **WHEN** пользователь убирает метку с фото
|
||||
- **THEN** метка отвязывается от фото
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Рабочий стол веб-приложения: постоянный каркас с рейлом альбомов, контекстной панелью,
|
||||
режимом просмотра фото и адаптивным поведением, обеспечивающий цельный «lightroom»-вид.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Рабочий стол приложения
|
||||
Аутентифицированные экраны SHALL отображаться внутри постоянного каркаса с топбаром, левым
|
||||
рейлом альбомов и контекстной правой панелью. Публичные экраны (вход, инвайт, share-ссылка)
|
||||
SHALL отображаться вне каркаса, но в общей теме оформления.
|
||||
|
||||
#### Scenario: Внутренний экран
|
||||
- **WHEN** аутентифицированный пользователь открывает список альбомов или альбом
|
||||
- **THEN** экран отрисован внутри каркаса с рейлом и панелью, рейл не перезагружается при переходе
|
||||
|
||||
#### Scenario: Публичный экран
|
||||
- **WHEN** пользователь открывает публичную share-ссылку без входа
|
||||
- **THEN** экран отрисован без рейла и контекстной панели, но в тех же цветовых токенах
|
||||
|
||||
### Requirement: Рейл альбомов
|
||||
Рейл SHALL постоянно показывать список доступных альбомов с их обложками и отмечать
|
||||
активный альбом.
|
||||
|
||||
#### Scenario: Активный альбом
|
||||
- **WHEN** пользователь находится в альбоме
|
||||
- **THEN** соответствующий элемент рейла выделен, остальные показывают обложки или плейсхолдер
|
||||
|
||||
### Requirement: Создание альбома
|
||||
Создание альбома SHALL выполняться через иконочную кнопку с подсказкой, открывающую
|
||||
модальное окно ввода названия, вместо инлайн-формы.
|
||||
|
||||
#### Scenario: Создание через модалку
|
||||
- **WHEN** пользователь нажимает кнопку создания, вводит название и подтверждает
|
||||
- **THEN** система создаёт альбом и делает его активным
|
||||
|
||||
#### Scenario: Пустое название
|
||||
- **WHEN** пользователь подтверждает пустое название
|
||||
- **THEN** создание не выполняется
|
||||
|
||||
### Requirement: Контекстная панель альбома
|
||||
Правая панель SHALL показывать превью обложки, название, счётчики фото и участников,
|
||||
управление обложкой и переходы к участникам, настройкам и ссылкам. Элементы управления
|
||||
обложкой SHALL быть доступны только owner и admin.
|
||||
|
||||
#### Scenario: Владелец альбома
|
||||
- **WHEN** owner открывает альбом
|
||||
- **THEN** панель показывает управление обложкой и переходы к участникам и настройкам
|
||||
|
||||
#### Scenario: Наблюдатель
|
||||
- **WHEN** viewer открывает альбом
|
||||
- **THEN** панель не показывает управление обложкой
|
||||
|
||||
### Requirement: Режим просмотра фотографии
|
||||
Просмотр фото SHALL быть отдельным маршрутом внутри каркаса с крупным изображением,
|
||||
лентой миниатюр снизу и панелью метаданных и комментариев. Тот же режим SHALL
|
||||
использоваться на публичных share-страницах.
|
||||
|
||||
#### Scenario: Открытие фото
|
||||
- **WHEN** пользователь выбирает фото в сетке
|
||||
- **THEN** открывается маршрут фото в каркасе, рейл и панель остаются доступны, адрес отражает фото
|
||||
|
||||
#### Scenario: Навигация в просмотре
|
||||
- **WHEN** пользователь использует стрелки влево/вправо или ленту миниатюр
|
||||
- **THEN** показывается соседнее фото, адрес обновляется
|
||||
|
||||
#### Scenario: Публичный просмотр
|
||||
- **WHEN** посетитель share-ссылки открывает фото
|
||||
- **THEN** используется тот же режим просмотра без рейла и панели
|
||||
|
||||
### Requirement: Плотность сетки
|
||||
Сетка SHALL давать пользователю управлять размером миниатюр, и выбранный размер SHALL
|
||||
сохраняться между сессиями.
|
||||
|
||||
#### Scenario: Изменение размера
|
||||
- **WHEN** пользователь двигает регулятор плотности
|
||||
- **THEN** размер миниатюр меняется, значение сохраняется и восстанавливается при следующем открытии
|
||||
|
||||
### Requirement: Загрузка фотографий из рабочего стола
|
||||
Загрузка SHALL запускаться действием в тулбаре; перетаскивание файлов над альбомом SHALL
|
||||
показывать оверлей и загружать их; пустой альбом SHALL показывать подсказку-зону загрузки.
|
||||
Загрузка SHALL быть доступна только ролям с правом загрузки.
|
||||
|
||||
#### Scenario: Перетаскивание
|
||||
- **WHEN** пользователь с правом загрузки перетаскивает файлы над альбомом
|
||||
- **THEN** показывается оверлей загрузки, и файлы загружаются
|
||||
|
||||
#### Scenario: Пустой альбом
|
||||
- **WHEN** пользователь открывает альбом без фото и имеет право загрузки
|
||||
- **THEN** в центре показана зона-подсказка загрузки
|
||||
|
||||
### Requirement: Темы и графитовый хром
|
||||
Тёмная тема SHALL использовать графитовый хром Lightroom и SHALL применяться по умолчанию;
|
||||
нейтральная тема SHALL быть тёплым графитом; остальные темы SHALL оставаться доступными, а
|
||||
скроллбары SHALL быть стилизованы в цвет темы.
|
||||
|
||||
#### Scenario: Первый вход
|
||||
- **WHEN** пользователь впервые открывает приложение без сохранённой темы
|
||||
- **THEN** применяется тёмная графитовая тема
|
||||
|
||||
#### Scenario: Смена темы
|
||||
- **WHEN** пользователь выбирает другую тему
|
||||
- **THEN** интерфейс, иконки и скроллбары меняются согласованно, и выбор сохраняется
|
||||
|
||||
#### Scenario: Нейтральная тема
|
||||
- **WHEN** пользователь выбирает нейтральную тему
|
||||
- **THEN** применяется тёплый графитовый хром, отличный от холодной тёмной темы
|
||||
|
||||
### Requirement: Адаптивность рабочего стола
|
||||
На узких экранах рейл SHALL становиться выдвижной панелью, контекстная панель —
|
||||
сворачиваемой секцией, а лента миниатюр — горизонтально прокручиваемой.
|
||||
|
||||
#### Scenario: Узкий экран
|
||||
- **WHEN** ширина окна меньше порога рабочего стола
|
||||
- **THEN** рейл скрыт за кнопкой открытия, панель сворачиваема, лента прокручивается горизонтально
|
||||
|
||||
### Requirement: Доступность рабочего стола
|
||||
Иконочные органы управления каркаса SHALL иметь доступные имена, панели SHALL быть
|
||||
достижимы с клавиатуры с видимым фокусом, анимации SHALL учитывать
|
||||
предпочтение уменьшенного движения.
|
||||
|
||||
#### Scenario: Клавиатурная навигация
|
||||
- **WHEN** пользователь работает только с клавиатуры
|
||||
- **THEN** органы управления каркаса достижимы, доступные имена присутствуют, фокус виден
|
||||
|
||||
### Requirement: Изменение ширины панелей
|
||||
Пользователь SHALL иметь возможность менять ширину левой и правой панелей перетаскиванием, а
|
||||
выбранная ширина SHALL сохраняться между сессиями.
|
||||
|
||||
#### Scenario: Перетаскивание разделителя
|
||||
- **WHEN** пользователь тянет разделитель у панели на рабочем столе
|
||||
- **THEN** панель меняет ширину в допустимых пределах
|
||||
|
||||
#### Scenario: Сохранение ширины
|
||||
- **WHEN** пользователь перезагружает приложение
|
||||
- **THEN** восстановленные панели имеют сохранённую ширину
|
||||
|
||||
### Requirement: Сворачиваемые разделы панели
|
||||
Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние
|
||||
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото,
|
||||
включая извлечённые EXIF-атрибуты.
|
||||
|
||||
#### Scenario: Сворачивание раздела
|
||||
- **WHEN** пользователь нажимает заголовок раздела
|
||||
- **THEN** содержимое раздела скрывается или показывается, состояние сохраняется
|
||||
|
||||
#### Scenario: Метаданные фото
|
||||
- **WHEN** открыт просмотр фото
|
||||
- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки, координаты и извлечённые EXIF-атрибуты
|
||||
|
||||
### Requirement: Полноэкранный просмотр фотографии
|
||||
В режиме просмотра SHALL быть доступен переход в полноэкранный режим кнопкой и хоткеем `f`.
|
||||
В полноэкранном режиме верхняя и нижняя панели SHALL скрываться и показываться у
|
||||
соответствующей кромки экрана.
|
||||
|
||||
#### Scenario: Вход в полный экран
|
||||
- **WHEN** пользователь нажимает кнопку полного экрана или клавишу `f`
|
||||
- **THEN** просмотр разворачивается на весь экран, панели скрыты
|
||||
|
||||
#### Scenario: Панель по кромке
|
||||
- **WHEN** курсор касается верхней или нижней кромки экрана
|
||||
- **THEN** соответствующая панель показывается, а при уходе курсора скрывается сразу
|
||||
|
||||
#### Scenario: Выход из полного экрана
|
||||
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
|
||||
- **THEN** полноэкранный режим закрывается
|
||||
|
||||
#### Scenario: Полноэкранный режим приложения
|
||||
- **WHEN** включён полноэкранный режим приложения, и пользователь нажимает `f`
|
||||
- **THEN** открывается полноэкранный просмотр фото (вложенно), а полноэкранный режим приложения не выключается
|
||||
|
||||
### Requirement: Зум в просмотре
|
||||
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
|
||||
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
|
||||
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
|
||||
работать и в полноэкранном режиме.
|
||||
|
||||
#### Scenario: Зум колесом к указателю
|
||||
- **WHEN** пользователь вращает колесо мыши над фото
|
||||
- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200%
|
||||
|
||||
#### Scenario: Пробел
|
||||
- **WHEN** пользователь нажимает `Space`
|
||||
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
|
||||
|
||||
#### Scenario: Оригинал в зуме
|
||||
- **WHEN** пользователь входит в зум
|
||||
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
||||
|
||||
#### Scenario: Стрелки соседних фото
|
||||
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
||||
|
||||
#### Scenario: Панорамирование
|
||||
- **WHEN** фото увеличено
|
||||
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
||||
|
||||
### Requirement: Удаление фото из просмотра
|
||||
Автор фото, владелец и админ альбома SHALL иметь возможность удалить текущее фото из режима
|
||||
просмотра; удаление SHALL подтверждаться диалогом.
|
||||
|
||||
#### Scenario: Подтверждение удаления
|
||||
- **WHEN** пользователь с правом удаления нажимает «Удалить»
|
||||
- **THEN** открывается диалог подтверждения, и после подтверждения фото удаляется
|
||||
|
||||
#### Scenario: Лента после удаления
|
||||
- **WHEN** фото удалено
|
||||
- **THEN** просмотр переходит к соседнему фото, а лента миниатюр обновляется без удалённого
|
||||
|
||||
### Requirement: Возврат к обзору
|
||||
Из альбома и из режима просмотра SHALL быть доступен возврат к обзору альбомов.
|
||||
|
||||
#### Scenario: Возврат из альбома
|
||||
- **WHEN** пользователь нажимает кнопку «назад» слева от названия альбома
|
||||
- **THEN** открывается обзор альбомов
|
||||
|
||||
#### Scenario: Возврат из просмотра
|
||||
- **WHEN** пользователь нажимает кнопку «назад» в режиме просмотра
|
||||
- **THEN** открывается альбом с сеткой фото
|
||||
|
||||
### Requirement: Настройки списка атрибутов информации
|
||||
Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим
|
||||
модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые
|
||||
атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и
|
||||
«снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле
|
||||
пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов
|
||||
(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние).
|
||||
|
||||
#### Scenario: Открытие настроек
|
||||
- **WHEN** пользователь нажимает иконку настроек в разделе «Информация»
|
||||
- **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все»
|
||||
|
||||
#### Scenario: Значения по умолчанию
|
||||
- **WHEN** у пользователя нет сохранённых настроек
|
||||
- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…»
|
||||
|
||||
#### Scenario: Сохранение настроек
|
||||
- **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет
|
||||
- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле
|
||||
|
||||
#### Scenario: Сброс настроек
|
||||
- **WHEN** пользователь сбрасывает настройки
|
||||
- **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями
|
||||
|
||||
### Requirement: Свёртка скрытых атрибутов
|
||||
Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент
|
||||
«…»; нажатие на него SHALL раскрывать все атрибуты текущего фото, включая скрытые. Если
|
||||
скрытых атрибутов нет, элемент «…» SHALL не отображаться.
|
||||
|
||||
#### Scenario: Скрытые атрибуты
|
||||
- **WHEN** в разделе «Информация» есть скрытые настройками атрибуты
|
||||
- **THEN** после видимых атрибутов показан элемент «…»
|
||||
|
||||
#### Scenario: Раскрытие всех атрибутов
|
||||
- **WHEN** пользователь нажимает «…»
|
||||
- **THEN** отображаются все атрибуты фото, включая скрытые
|
||||
|
||||
#### Scenario: Нет скрытых атрибутов
|
||||
- **WHEN** ни один атрибут не скрыт
|
||||
- **THEN** элемент «…» не отображается
|
||||
|
||||
### Requirement: Выбор фото в сетке
|
||||
В режиме альбома одиночный выбор фото SHALL отмечать его рамкой и показывать его сведения в
|
||||
контекстной панели, не открывая просмотр; двойной выбор SHALL открывать режим просмотра фото.
|
||||
|
||||
#### Scenario: Одиночный выбор
|
||||
- **WHEN** пользователь кликает по фото в сетке
|
||||
- **THEN** фото отмечено рамкой, правая панель показывает сведения об этом фото, просмотр не открывается
|
||||
|
||||
#### Scenario: Двойной выбор
|
||||
- **WHEN** пользователь дважды кликает по фото в сетке
|
||||
- **THEN** открывается режим просмотра этого фото
|
||||
|
||||
#### Scenario: Смена альбома
|
||||
- **WHEN** пользователь переходит в другой альбом
|
||||
- **THEN** отметка выбора сбрасывается
|
||||
|
||||
### Requirement: Мультивыбор фото в сетке
|
||||
В режиме альбома пользователь SHALL выбирать несколько фото: обычный клик — одно фото,
|
||||
Ctrl/Cmd-клик — добавить или убрать, Shift-клик — диапазон. Выбранные фото SHALL быть отмечены.
|
||||
|
||||
#### Scenario: Мультивыбор
|
||||
- **WHEN** пользователь кликает по фото с зажатым Ctrl/Cmd или Shift
|
||||
- **THEN** соответствующие фото отмечены, а панель показывает количество выбранных
|
||||
|
||||
### Requirement: Действия с выбранными фото
|
||||
Когда выбрано одно или несколько фото, правая панель SHALL показывать раздел «Действия» со
|
||||
строками-пунктами (иконка + подпись) «Открыть (просмотр)», «Переместить», «Копировать» и
|
||||
«Удалить»; удаление SHALL подтверждаться диалогом.
|
||||
|
||||
#### Scenario: Действия
|
||||
- **WHEN** выбрано одно или несколько фото
|
||||
- **THEN** панель показывает раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать» и «Удалить»
|
||||
|
||||
### Requirement: Перетаскивание выбранных фото на альбом
|
||||
Выбранные фото SHALL перетаскиваться на альбом в рейле; при отпускании SHALL открываться
|
||||
диалог выбора переноса или копирования с указанием целевого альбома. Во время такого
|
||||
перетаскивания зона загрузки файлов SHALL не показываться.
|
||||
|
||||
#### Scenario: Перетаскивание на альбом
|
||||
- **WHEN** пользователь перетаскивает выделенные фото на альбом в рейле и отпускает
|
||||
- **THEN** открывается диалог с выбором переноса/копирования и целевым альбомом
|
||||
|
||||
### Requirement: Корзина в списке альбомов
|
||||
Рейл альбомов SHALL содержать вход в корзину последним элементом списка (с иконкой корзины).
|
||||
Экран обзора альбомов SHALL не показывать пустую правую панель.
|
||||
|
||||
#### Scenario: Вход в корзину
|
||||
- **WHEN** пользователь открывает приложение
|
||||
- **THEN** в левом рейле последним элементом списка показана «Корзина» с иконкой корзины
|
||||
|
||||
#### Scenario: Обзор без панели
|
||||
- **WHEN** пользователь открывает обзор альбомов
|
||||
- **THEN** правая панель не отображается
|
||||
|
||||
### Requirement: Действия в режиме просмотра
|
||||
В режиме просмотра правая панель SHALL содержать раздел «Действия» со строками-пунктами
|
||||
(иконка + подпись) «Скачать», «Переместить», «Копировать» и «Удалить» (удаление — по правам).
|
||||
Верхняя панель просмотра SHALL не дублировать скачивание и удаление.
|
||||
|
||||
#### Scenario: Действия в просмотре
|
||||
- **WHEN** открыт режим просмотра фото
|
||||
- **THEN** раздел «Действия» содержит «Скачать», «Переместить», «Копировать» и «Удалить», а в верхней панели этих кнопок нет
|
||||
|
||||
#### Scenario: Удаление из панели
|
||||
- **WHEN** пользователь удаляет фото из раздела «Действия»
|
||||
- **THEN** открывается подтверждение, и после удаления просмотр переходит к соседнему фото
|
||||
|
||||
### Requirement: Контекстное меню фото
|
||||
Правый клик по фото в сетке и по изображению в режиме просмотра SHALL открывать контекстное
|
||||
меню с пунктами раздела «Действия» («Скачать», «Переместить», «Копировать», «Удалить»; в сетке
|
||||
также «Открыть (просмотр)»), выполняющими те же действия.
|
||||
|
||||
#### Scenario: Меню в сетке
|
||||
- **WHEN** пользователь кликает правой кнопкой по фото в сетке
|
||||
- **THEN** открывается меню с пунктами «Открыть (просмотр)», «Скачать», «Переместить», «Копировать», «Удалить»
|
||||
|
||||
#### Scenario: Меню в просмотре
|
||||
- **WHEN** пользователь кликает правой кнопкой по изображению в режиме просмотра
|
||||
- **THEN** открывается меню с пунктами «Скачать», «Переместить», «Копировать», «Удалить»
|
||||
|
||||
### Requirement: Контекстное меню альбома
|
||||
Правый клик по альбому в рейле и по карточке на обзоре SHALL открывать меню с пунктами
|
||||
«Переименовать» и «Удалить»; переименование SHALL открывать диалог, удаление — подтверждение.
|
||||
|
||||
#### Scenario: Меню альбома
|
||||
- **WHEN** пользователь кликает правой кнопкой по альбому
|
||||
- **THEN** открывается меню с «Переименовать» и «Удалить»
|
||||
|
||||
#### Scenario: Удаление альбома
|
||||
- **WHEN** пользователь подтверждает удаление альбома
|
||||
- **THEN** альбом удаляется, а его фото попадают в корзину
|
||||
|
||||
### Requirement: Счётчики в рейле
|
||||
Рейл SHALL показывать число фото у каждого альбома и число элементов в корзине.
|
||||
|
||||
#### Scenario: Счётчики
|
||||
- **WHEN** пользователь смотрит на рейл альбомов
|
||||
- **THEN** у каждого альбома показано число фото, а у корзины — число удалённых фото
|
||||
|
||||
### Requirement: Метки в панели фото
|
||||
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
|
||||
поиск по меткам пользователя и создание новых.
|
||||
|
||||
#### Scenario: Раздел «Метки»
|
||||
- **WHEN** открыт просмотр или выбран один фото в сетке
|
||||
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
|
||||
|
||||
### Requirement: Компактный верхний хром
|
||||
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
|
||||
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
|
||||
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
|
||||
увеличивая область фото.
|
||||
|
||||
#### Scenario: Компактные верхние панели
|
||||
- **WHEN** открыт список альбомов или режим просмотра фото
|
||||
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
|
||||
|
||||
#### Scenario: Прозрачная панель просмотра
|
||||
- **WHEN** открыт режим просмотра фото
|
||||
- **THEN** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения
|
||||
@@ -0,0 +1,85 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Backend: обложка и целостность
|
||||
|
||||
- [x] 1.1 Добавить миграцию `000002_album_cover_fk` (up/down): обнулить висячие `cover_photo_id`, затем `ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL`. Проверка: `make migrate` применяется, `\d albums` показывает constraint, down-миграция снимает его.
|
||||
- [x] 1.2 Добавить `CoverThumbURL` в `models.Album` (json `cover_thumb_url,omitempty`). Проверка: `cd backend && go build ./...` проходит.
|
||||
- [x] 1.3 В `albums/store.go` добавить `LEFT JOIN photos p ON p.id = a.cover_photo_id` и выбирать `p.thumb_key AS cover_thumb_key` в `ListForUser` и `GetByID`. Проверка: запрос возвращает `cover_thumb_key` для альбома с обложкой и `NULL` без неё.
|
||||
- [x] 1.4 В `albums/service.go` пресигнить `cover_thumb_key` в `CoverThumbURL` в `List` и `Get`. Проверка: `GET /api/albums` в ответе содержит `cover_thumb_url` у альбома с обложкой.
|
||||
- [x] 1.5 В `photos/service.go` вызывать `SetCover` только при `cover_photo_id IS NULL`. Проверка: загрузка двух фото в новый альбом оставляет обложкой первое; после удаления фото-обложки cover становится `NULL`, следующая загрузка ставит новый.
|
||||
- [x] 1.6 Покрыть тестами store-join обложки и автообложку; проверить `cd backend && go test ./...`.
|
||||
|
||||
## 2. Frontend: темы и каркас
|
||||
|
||||
- [x] 2.1 Добавить токены `--chrome`/`--canvas`, перенастроить `dark` в графит и сделать его дефолтом (при отсутствии сохранённого выбора). Проверка: `cd frontend && npm run check` проходит, все три темы визуально согласованы.
|
||||
- [x] 2.2 Добавить `cover_thumb_url` в `lib/types.ts` и недостающие иконки (обложка, плотность/меню) в `lib/icons/registry.ts`. Проверка: `npm run check` без ошибок типов.
|
||||
- [x] 2.3 Разбить `+layout.svelte` на группы `(app)`/`(public)` и ввести `AppShell` (топбар, слот рейла, слот панели, центр = маршрут). Проверка: навигация между альбомами не перезагружает рейл; `/login`, `/invites/[token]`, `/share/[token]` рендерятся без каркаса.
|
||||
- [x] 2.4 Реализовать адаптивный каркас: рейл → drawer, панель → сворачиваемая секция, лента → горизонтальный скролл на узкой ширине. Проверка: при ширине меньше порога каркас управляем без горизонтального overflow.
|
||||
|
||||
## 3. Рейл и создание альбома
|
||||
|
||||
- [x] 3.1 Компонент `AlbumRail`: список альбомов с обложками, плейсхолдер, подсветка активного. Проверка: активный альбом выделен, остальные показывают обложку/плейсхолдер.
|
||||
- [x] 3.2 Кнопка создания `+` с хинтом и модалка ввода названия (замена инлайн-формы). Проверка: создание альбома через модалку, новый альбом становится активным; пустое название не создаёт альбом.
|
||||
|
||||
## 4. Контекстная панель и обложки
|
||||
|
||||
- [x] 4.1 Компонент `AlbumPanel`: превью обложки, название, счётчики, переходы к участникам/настройкам/ссылкам; управление обложкой только для owner/admin. Проверка: viewer не видит управление обложкой.
|
||||
- [x] 4.2 Модальный `CoverPicker` (сетка миниатюр) + `PATCH /albums/:id` с `cover_photo_id`/`clear_cover`. Проверка: назначение и сброс обложки отражаются в рейле, карточке и панели.
|
||||
- [x] 4.3 Выбор обложки выполняется только из правой панели (модальный пикер); действие «Сделать обложкой» в тулбаре просмотра не используется. Проверка: обложка задаётся и сбрасывается из панели, в тулбаре просмотра кнопки нет.
|
||||
|
||||
## 5. Центр: сетка, плотность, загрузка
|
||||
|
||||
- [x] 5.1 Сетка альбомов edge-to-edge с реальными обложками вместо карточек-заглушек. Проверка: карточки показывают `cover_thumb_url`, при отсутствии — плейсхолдер.
|
||||
- [x] 5.2 Регулятор плотности миниатюр в тулбаре сетки с сохранением в `localStorage`. Проверка: размер меняется и восстанавливается после перезагрузки.
|
||||
- [x] 5.3 Загрузка: иконочная кнопка в тулбаре, полноэкранный drag-overlay, зона-подсказка в пустом альбоме; прогресс компактной полосой. Проверка: перетаскивание и выбор файлов загружают фото, пустой альбом показывает подсказку.
|
||||
|
||||
## 6. Просмотр как маршрут
|
||||
|
||||
- [x] 6.1 Маршрут `albums/[id]/photos/[photoId]` и компонент `Viewer` (крупное фото, filmstrip, панель метаданных/комментариев, тулбар, клавиши ←/→/Esc). Проверка: переход из сетки открывает маршрут, адрес отражает фото, навигация синхронна с filmstrip.
|
||||
- [x] 6.2 Переиспользовать `Viewer` на `share/[token]` вместо инлайн-лайтбокса. Проверка: публичный просмотр использует тот же компонент без рейла/панели.
|
||||
|
||||
## 7. Интеграция
|
||||
|
||||
- [x] 7.1 Пройти сценарий: создать альбом → загрузить фото → задать обложку → открыть просмотр → открыть share-ссылку; проверить поведение на десктопе и узком экране.
|
||||
- [x] 7.2 Проверить `make build` (сборка фронтенда в `backend/static`, статический Go-бинарник) и `openspec validate "lightroom-workspace-ui"`.
|
||||
|
||||
## 8. Дополнительно реализовано (после планирования)
|
||||
|
||||
- [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит.
|
||||
- [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото.
|
||||
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, в зуме скрыты); поведение без таймеров — панели и стрелки показываются/скрываются сразу, стрелки только в границах вьюера; зум колесом к указателю без прокрутки (макс 200%), `Space` возвращает из любого зума во вписанный вид, в зуме подгружается оригинал. Проверка: поведение подтверждено в браузере (автоскрытие, зум к указателю, предел 200%, подмена превью→оригинал).
|
||||
- [x] 8.4 Сворачиваемые разделы правой панели с сохранением состояния и раздел «Информация» (EXIF). Проверка: состояние сохраняется между сессиями, метаданные отображаются.
|
||||
- [x] 8.5 Ресайз левой и правой панелей перетаскиванием с сохранением ширины. Проверка: ширина восстанавливается после перезагрузки.
|
||||
- [x] 8.6 Лимит загрузки 100 МБ и понятные ошибки (клиентская проверка и `413 file_too_large`). Проверка: 60 МБ проходит разбор, превышение лимита даёт явную ошибку размера.
|
||||
- [x] 8.7 Счётчики `photos_count`/`members_count` в ответе альбома. Проверка: `GET /api/albums` и `GET /api/albums/:id` содержат счётчики.
|
||||
- [x] 8.8 Тема и хром: нейтральная тема — тёплый графит, порядок и значок переключателя, стилизация скроллбаров. Проверка: темы переключаются согласованно, скроллбары в тон темы.
|
||||
- [x] 8.9 Извлечение EXIF: парсер заменён на `github.com/dsoprea/go-exif/v3` (все распознаваемые теги, нормализация чисел), хранение в `photos.exif` (миграция `000003_photo_exif`, jsonb `'{}'` по умолчанию), отдача в API фото; дата съёмки и GPS дублируются в `taken_at`/координаты. Проверка: реальное фото HUAWEI nova 15 Pro даёт 67 атрибутов; `go test ./...`.
|
||||
- [x] 8.10 Бэкфилл метаданных существующих фото: команда `cmd/backfill-exif` + `storage.Get` + `photos.Store.BackfillEXIF` (тянет оригиналы из S3, заполняет `exif`/`taken_at`/координаты). Проверка: прогон обновил 25 фото, `taken_at` проставлен.
|
||||
- [x] 8.11 Раздел «Информация» показывает все EXIF-атрибуты с русскими подписями, переводом enum и единицами (выдержка `1/N`, `f/N`, `N мм`, EV); служебные/дублирующие теги скрыты. Проверка: Playwright на реальном фото — 9/9.
|
||||
|
||||
## 9. Настройки информации пользователя
|
||||
|
||||
- [x] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает.
|
||||
- [x] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется.
|
||||
- [x] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы.
|
||||
- [x] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`.
|
||||
- [x] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле.
|
||||
- [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
|
||||
- [x] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||
- [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12).
|
||||
- [x] 9.9 Выбор фото в сетке: одиночный клик выделяет фото рамкой и показывает его сведения в правой панели, двойной — открывает просмотр; стор `selection`, сброс при смене альбома. Проверка: Playwright — выделение без навигации, панель показывает инфо, двойной клик открывает просмотр (5/5).
|
||||
- [x] 9.10 Вьюер и модалки: убраны таймеры автоскрытия (панели/стрелки мгновенно), стрелки появляются только в границах вьюера, `f`/кнопка переключают вложенный fullscreen вьюера (не выключают режим приложения); модалки рендерятся порталом в `document.body` (центрирование во вьюпорте). Проверка: Playwright — стрелки 4/4, fullscreen+f 6/6, центрирование 2/2.
|
||||
- [x] 9.11 Корзина: миграция `000005_photo_trash` (`deleted_at`), мягкое удаление (`DELETE /photos/:id`), `GET /trash`, `POST /photos/:id/restore`, `DELETE /photos/:id/permanent` с удалением объектов S3 только при отсутствии ссылок на `storage_key`. Проверка: удаление → в корзине, восстановление возвращает, копия сохраняет объект.
|
||||
- [x] 9.12 Перенос/копирование: `POST /photo-batch/{delete,move,copy}`, клон записи с общими объектами S3 (метаданные), события SSE для источника и цели. Проверка: копия даёт N фото в цели, перенос убирает из источника.
|
||||
- [x] 9.13 Фронтенд: стор `selection` (Ctrl/Shift-мультивыбор), действия в панели «Удалить/Переместить/Копировать», drag выделенных на альбом рейла, глобальный `TransferDialog`. Проверка: Playwright — мультивыбор и copy 9/9, drag+move 4/4.
|
||||
- [x] 9.14 Рейл: вход «Корзина» последним элементом списка с иконкой, скрытие пустой правой панели (`panelVisible`), маршрут `/trash` с восстановлением и окончательным удалением. Проверка: в рейле есть вход в корзину, на обзоре нет правой панели.
|
||||
- [x] 9.15 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||
- [x] 9.16 Раздел «Действия» в панели — пункты-надписи с иконками (не кнопки): «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; надпись «Выбрано:» убрана. Проверка: Playwright — «Открыть (просмотр)» открывает просмотр, раздел присутствует (4/4).
|
||||
- [x] 9.17 Компактный верхний хром: верхняя панель приложения и верхняя панель режима просмотра сведены к минимуму (узкие отступы, иконочные кнопки `size-4`, компактный переключатель темы). Проверка: высота `<header>` ~31px, верхней панели просмотра ~29px; `npm run check`.
|
||||
- [x] 9.18 Действия просмотра перенесены в правую панель: в разделе «Действия» — «Скачать», «Переместить», «Копировать», «Удалить»; из верхней панели просмотра скачивание и удаление убраны; удаление через стор `viewerActions` и страницу просмотра. Проверка: Playwright — верх без скачать/удалить, панель с действиями, удаление из панели переходит к соседнему фото (9/9).
|
||||
- [x] 9.19 Единая панель фото: содержимое для одного фото вынесено в общий компонент `PhotoPanel`, переиспользуемый в просмотре и при одиночном выборе в сетке (не копия); мультивыбор — только «Действия». Проверка: Playwright — просмотр 9/9, выбор 5/5, мультивыбор 9/9; `npm run check`.
|
||||
- [x] 9.20 Контекстное меню фото (правая кнопка) в сетке и просмотре с пунктами из «Действий»; общий стор `contextMenu` и глобальный `ContextMenu`. Проверка: Playwright — меню в сетке (5 пунктов), меню в просмотре (4 пункта), «Открыть» из меню и «Переместить» → диалог (12/12).
|
||||
- [x] 9.21 Контекстное меню альбома («Переименовать»/«Удалить») в рейле и на обзоре; диалоги в `AlbumActionsDialogs`. Проверка: Playwright — меню, переименование, удаление (4/4).
|
||||
- [x] 9.22 Удаление альбома помещает фото в корзину без потери файлов (FK `photos.album_id` → `ON DELETE SET NULL`, миграция `000006`); восстановление без альбома предлагает выбрать/создать альбом (`album_required` + `POST /photos/:id/restore {album_id}`). Проверка: Playwright — удалить альбом → фото в корзине, диалог восстановления, фото в новом альбоме (7/7).
|
||||
- [x] 9.23 Счётчики в рейле: число фото у альбома и число элементов корзины (`GET /trash/count`, стор `trash`); обновление счётчика альбома после загрузки/удаления. Проверка: Playwright — альбом 2, корзина 0→1 (3/3).
|
||||
- [x] 9.24 Метки фото (область — пользователь): таблицы `tags`/`photo_tags` (миграция `000007`), `GET /tags?q=`, `POST /photos/:id/tags`, `DELETE /photos/:id/tags/:tag_id`, метки в ответе фото; раздел «Метки» в панели с чипами, поиском и созданием. Проверка: API — создание/поиск/снятие; Playwright — создание, снятие, выбор из поиска (4/4).
|
||||
Reference in New Issue
Block a user