Files
ds-galo/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/design.md
T
iu 384f126d8a 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/
2026-10-10 22:47:33 +03:00

314 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` или кастомный тултип — косметика, спеки не меняет.