# 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) — избыточно для локального выделения. ## 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` или кастомный тултип — косметика, спеки не меняет.