103 lines
6.7 KiB
Markdown
103 lines
6.7 KiB
Markdown
# Design
|
||
|
||
## Context
|
||
|
||
См. `proposal.md` для мотивации. Текущее состояние, важное для подхода:
|
||
|
||
- Авторизация — Bearer-токен (не cookies), поэтому `<img>` не может обращаться к
|
||
защищённым эндпоинтам напрямую. Все фото уже отдаются фронтенду как presigned
|
||
URL (`internal/photos/service.go`, `attachURLs`).
|
||
- `albums.cover_photo_id` — колонка `text` без внешнего ключа (миграция
|
||
`000001_init.up.sql:15`), поэтому удаление фото-обложки оставляет висячий id.
|
||
- `albums.Service` уже держит `*storage.Client` и умеет presign; отдельной
|
||
зависимости от пакета `photos` нет.
|
||
- Сборка фото идёт асинхронно (`processVariants`): `thumb_key` появляется не
|
||
сразу после создания записи фото.
|
||
- В `backend/static` закоммичен собранный `Icon.svelte` (inline-SVG спрайт
|
||
Material Symbols), при этом в `frontend/src` компонента нет — UI сейчас на
|
||
эмодзи. Берём inline-SVG как ориентир.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Список альбомов отдаёт `cover_url` (presigned миниатюра) и карточки его рисуют.
|
||
- Выбор и очистка обложки из фото альбома в настройках, применяется немедленно.
|
||
- Автоматическая обложка не затирает ручной выбор.
|
||
- Кнопка создания — иконка с хинтом, открывающая диалог ввода имени.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Обложка в шапке страницы альбома и в публичных share-страницах.
|
||
- Смена обложки из лайтбокса/контекстного меню фото (может быть позже).
|
||
- Переработка всей иконочной системы проекта — вводим минимальный inline-SVG.
|
||
|
||
## Decisions
|
||
|
||
### 1. `cover_url` в ответе списка (вариант A)
|
||
|
||
`GET /albums` отдаёт каждому альбому `cover_url` — presigned URL миниатюры
|
||
(`thumb`) или `null`. Источник ключа: `albums.Store` расширяет выборку
|
||
подзапросом к `photos` за `thumb_key` по `cover_photo_id`; `albums.Service`
|
||
делает presign через уже имеющийся `storage.Client`.
|
||
|
||
**Альтернативы:** (B) фронт дёргает `GET /photos/:id` на каждую обложку — N+1 и
|
||
тянет лишние поля; (C) эндпоинт `GET /albums/:id/cover` с редиректом — не
|
||
работает, `<img>` не приложит Bearer-токен. Оба отпадают.
|
||
|
||
### 2. Presign в `albums`, а не в `photos`
|
||
|
||
Не вводим зависимость `albums -> photos`; достаточно подзапроса за ключом и
|
||
существующего `storage` в `albums.Service`. Дублирование кода presign
|
||
минимально и оправдано сохранением слоёв.
|
||
|
||
### 3. Авто-обложка только при пустой
|
||
|
||
`photos.Store.SetCover` меняет SQL на условный:
|
||
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`.
|
||
Вызов остаётся в `processVariants` после `SetVariants`, чтобы ключ миниатюры уже
|
||
существовал. Это же сохраняет ручной выбор при последующих загрузках.
|
||
|
||
### 4. Валидация обложки при PATCH
|
||
|
||
`albums.Service.Update` при заданном `cover_photo_id` SHALL проверять, что фото
|
||
принадлежит этому альбому (иначе обложкой станет произвольный чужой id). Ошибка —
|
||
`BadRequest`/`NotFound`. `clear_cover` по-прежнему очищает.
|
||
|
||
### 5. Иконка — inline-SVG, диалог — нативный `<dialog>`
|
||
|
||
Минимальный компонент иконки (один SVG на кнопку создания) с `title` +
|
||
`aria-label` для хинта. Диалог имени — нативный `<dialog>`: ESC, backdrop и
|
||
focus-trap бесплатно, не тащим оверлей как в `Lightbox`. Поле — только
|
||
«Название»; описание остаётся в настройках.
|
||
|
||
### 6. Очистка висячей обложки при удалении фото
|
||
|
||
Так как FK нет, при удалении фото, которое является обложкой, бэкенд SHALL
|
||
очищать `cover_photo_id` (в `photos.Service.Delete` или через `SetCover`-подобный
|
||
метод). Иначе получим обложку на несуществующее фото (визуально — заглушка).
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- [Presigned `cover_url` живёт ~1 час, а список может висеть открытым дольше] →
|
||
Приемлемо: список перезагружается при навигации; при желании позже добавим
|
||
refetch. Для MVP не блокирует.
|
||
- [Подзапрос за `thumb_key` на каждый альбом в списке] → Дёшево при индексе
|
||
`photos` по PK; presign — локальная подпись без сетевого вызова.
|
||
- [`cover_photo_id` задан, но варианты ещё не собраны] → `thumb_key` пуст →
|
||
`cover_url = null` → карточка показывает заглушку; авто-обложка ставится только
|
||
после сборки вариантов, ручной выбор сразу после загрузки может временно не
|
||
иметь миниатюры.
|
||
- [Пользователь уберёт обложку во время загрузки нового фото] → авто-обложка
|
||
снова назначит свежее фото, если обложка пуста — ожидаемо по спеке.
|
||
|
||
## Migration Plan
|
||
|
||
Миграции не нужны. Изменения аддитивны: новое поле `cover_url` в JSON; старые
|
||
клиенты его игнорируют. Порядок выката: бэкенд, затем фронтенд. Откат —
|
||
возврат предыдущего бинарника и статики; данные не меняются.
|
||
|
||
## Open Questions
|
||
|
||
Нет.
|