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.
169 lines
15 KiB
Markdown
169 lines
15 KiB
Markdown
# 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` или кастомный тултип — косметика, спеки не меняет.
|