- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count - frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs - openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
32 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)
Правая панель даёт [Выбрать обложку] (модальный пикер из миниатюр альбома) и [Сбросить];
всё под 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
- Миграция
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. - Миграция
000003_photo_exif:ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT NULL DEFAULT '{}'::jsonb. Down —DROP COLUMN. - Миграция
000004_user_preferences:ALTER TABLE users ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'::jsonb. Down —DROP COLUMN. - Бэкенд:
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(GetUserByIDcpreferences,Meотдаёт настройки,PATCH /auth/me/preferencesс валидацией). - Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
разделами и EXIF, модалки,
Viewerс зумом), слот действий и модалка настроек атрибутов, стор настроек,types.ts. - Откат: снять constraint и колонки
photos.exif/users.preferences(down-миграции); фронтенд откатывается независимо, поля ответа игнорируются старым клиентом.
Open Questions
- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не меняет.
- Хинты: нативный
titleили кастомный тултип — косметика, спеки не меняет.