Backend: - expose cover thumbnail URL on album responses (LEFT JOIN + presign) - auto-assign cover only when empty; FK cover_photo_id -> photos ON DELETE SET NULL - add photos_count / members_count to album responses; cover repo test Frontend: - split layouts into (app) shell (resizable rail + contextual panel) and (public) - album rail + create-album dialog; album panel with cover picker / make-cover - photo viewer route with filmstrip, replacing lightbox; share reuses it - grid density slider, toolbar + drag-and-drop upload, dark graphite default OpenSpec: add lightroom-workspace-ui change (proposal, design, specs, tasks)
11 KiB
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, созданный альбом становится активным. Инлайн-форма удаляется.
Risks / Trade-offs
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети; при необходимости кэшировать URL на короткий TTL.
- [Presigned URL обложки истекает при долго открытой странице] → URL обновляется при загрузке списка; при ошибке загрузки картинки показывать плейсхолдер.
- [Циклический FK albums↔photos] → миграция обнуляет висячие id до создания constraint; каскадное удаление альбома обрабатывается БД (SET NULL по уже удаляемой строке безвреден).
- [Реструктуризация
+layoutзадевает все маршруты] → выносить каркас инкрементально, группы(app)/(public)в одном шаге, публичные страницы регрессионно проверяем. - [Добавление токенов темы задевает все экраны] → токены аддитивны, старые классы продолжают работать; проверка триад тем.
- [Пачка загрузок: обложкой становится первое обработанное фото] → принято; дальнейшие загрузки обложку не трогают, пользователь может задать явно.
- [Дополнительное состояние в маршруте просмотра (filmstrip, клавиатура)] → единый компонент
Viewerс props и общим источником фото.
Migration Plan
- Миграция
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. - Бэкенд:
models.Album(+CoverThumbURL),albums/store.go(LEFT JOIN),albums/service.go(enrich + presign),photos/service.go(SetCover только если NULL). - Фронтенд: каркас и маршруты, компоненты, токены,
types.ts. - Откат: снять constraint (down-миграция); фронтенд откатывается независимо, поле ответа игнорируется старым клиентом.
Open Questions
- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не меняет.
- Хинты: нативный
titleили кастомный тултип — косметика, спеки не меняет.