Files
ds-galo/openspec/changes/lightroom-workspace-ui/design.md
T
iu 1b4906648d feat(preferences): configurable photo info attributes stored in user profile
- backend: users.preferences jsonb (migration 000004), Me returns preferences, PATCH /auth/me/preferences with validation (visible list + labels)
- frontend: gear in info section opens PhotoInfoSettingsDialog; base attribute set shown by default, remaining under ellipsis; per-attribute visibility and custom labels persisted to the profile; reset clears settings from the profile
- frontend: dialog has select-all/deselect-all icon buttons (no 'show all'); EXIF constants extracted to lib/exif.ts
- fix: render modal overlays via portal to document.body so they center in the viewport despite transformed panel/rail ancestors
- openspec: sync lightroom-workspace-ui artifacts (spec/design/proposal/tasks 9.8)
2026-10-10 21:04:08 +03:00

21 KiB
Raw Blame History

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. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото скрыты и всплывают у соответствующих кромок (панели — по касанию края, скрытие ~1 с после ухода; стрелки — при курсоре в 1/6 левой/правой области, скрытие ~300 мс; в зуме стрелки скрыты полностью). Зум — состояние 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/координаты. Альтернатива — фиксированный набор полей — отклонена: пользователю нужны все атрибуты.

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 или кастомный тултип — косметика, спеки не меняет.