Files
ds-galo/openspec/changes/lightroom-workspace-ui/design.md
T
iu dbf116999d chore(openspec): sync lightroom-workspace-ui with implemented changes
Reflect post-planning work in proposal, design, specs (albums/photos/
ui-workspace) and tasks: photo rename, viewer delete/zoom/fullscreen,
resizable and collapsible panels with EXIF, upload size limit, album
counts, themes/scrollbars, back navigation.
2026-10-10 19:45:55 +03:00

169 lines
15 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+T2)
Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`;
в просмотре — действие «Сделать обложкой» для текущего фото. Всё под `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`. Зум — локальные `zoomed`/`scale`: колесо меняет масштаб с привязкой к
курсору; `Space` переключает 100% (1:1); при увеличении изображение панорамируется
перетаскиванием и скроллбарами. Хоткеи игнорируются при фокусе в поле ввода. Зум
сбрасывается при смене фото. Альтернатива — только `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`, страница альбома
обновляет запись в списке.
## 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.
- [Сохранённые ширина/состояние панелей] → значения ограничиваются допустимым диапазоном.
## 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. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
(LEFT JOIN + счётчики), `albums/service.go` (enrich + presign), `photos` (автообложка только
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`).
3. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
разделами и EXIF, модалки, `Viewer` с зумом), стор альбомов, токены и скроллбары, `types.ts`.
4. Откат: снять constraint (down-миграция); фронтенд откатывается независимо, поле ответа
игнорируется старым клиентом.
## Open Questions
- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не
меняет.
- Хинты: нативный `title` или кастомный тултип — косметика, спеки не меняет.