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:
iu
2026-10-10 22:47:33 +03:00
parent 61b45b2742
commit 384f126d8a
12 changed files with 578 additions and 2 deletions
@@ -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` или кастомный тултип — косметика, спеки не меняет.