This commit is contained in:
iu
2026-10-10 14:21:43 +03:00
parent 84ce4d1930
commit 9329ebc023
17 changed files with 591 additions and 34 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-10
+102
View File
@@ -0,0 +1,102 @@
# 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
Нет.
@@ -0,0 +1,50 @@
# Proposal
## Why
Список альбомов пустой и безликий: карточки показывают заглушку `🖼️`, а создание
альбома — постоянная inline-форма в заголовке. При этом бэкенд уже хранит
`cover_photo_id` и умеет его менять, но фронтенд нигде не отдаёт URL обложки и не
даёт её выбрать. Нужно довести UI до состояния, где альбомы узнаваемы по
обложкам, а создание не занимает место в шапке.
## What Changes
- Кнопка «Создать» в списке альбомов превращается в компактную иконку с хинтом
(`title` + `aria-label`). По клику открывается диалог с полем «Название»;
отправка создаёт альбом через существующий `POST /albums`.
- `GET /albums` начинает возвращать `cover_url` — presigned URL миниатюры
обложки (или `null`), чтобы карточка списка могла показать изображение.
- Карточки альбомов в списке показывают обложку; при её отсутствии — прежнюю
заглушку.
- В настройках альбома появляется секция «Обложка»: сетка миниатюр фото
альбома, клик по фото применяет его обложкой (`PATCH` с `cover_photo_id`),
кнопка «Убрать» очищает (`clear_cover`).
- Автоматическая обложка при загрузке фото ставится только если обложка ещё
пустая, чтобы не затирать ручной выбор владельца.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `albums`: создание альбома через иконку-кнопку и диалог; `cover_url` в списке
альбомов; выбор и очистка обложки в настройках; правило автоматической
обложки (только при пустой).
## Impact
- **Frontend:** `src/routes/+page.svelte` (иконка, диалог, рендер обложки),
`src/routes/albums/[id]/settings/+page.svelte` (секция «Обложка»),
`src/lib/types.ts` (`Album.cover_url`), возможно новый переиспользуемый
inline-SVG компонент иконки.
- **Backend:** `internal/albums/store.go` и `internal/albums/service.go`
(получение thumb-ключа обложки и presign `cover_url`), `internal/photos/store.go`
(`SetCover` только при пустой обложке).
- **API:** ответ `GET /albums` расширяется полем `cover_url` (аддитивно,
обратносовместимо). `PATCH /albums/:id` уже поддерживает `cover_photo_id` и
`clear_cover` — изменений контракта не требуется.
- **Данные/миграции:** не требуются.
@@ -0,0 +1,68 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Создание альбома
Зарегистрированный пользователь SHALL иметь возможность создать альбом и стать его владельцем.
#### Scenario: Создание альбома
- **WHEN** пользователь создаёт альбом с названием
- **THEN** система создаёт альбом и назначает пользователя владельцем
#### Scenario: Создание через диалог из списка
- **WHEN** пользователь в списке альбомов активирует кнопку создания и вводит название в открывшемся диалоге
- **THEN** система создаёт альбом с указанным названием и показывает его в списке
### Requirement: Редактирование альбома
Владелец и админ SHALL иметь возможность редактировать название, описание и обложку альбома.
#### Scenario: Редактирование альбома
- **WHEN** админ изменяет описание альбома
- **THEN** система сохраняет изменения
#### Scenario: Выбор обложки из фото альбома
- **WHEN** владелец или админ выбирает одно из фото альбома в качестве обложки
- **THEN** система сохраняет выбранное фото как обложку альбома
#### Scenario: Очистка обложки
- **WHEN** владелец или админ убирает обложку альбома
- **THEN** система очищает обложку альбома
#### Scenario: Обложка недоступна без прав
- **WHEN** участник без прав редактирования альбома пытается изменить обложку
- **THEN** система отклоняет запрос
#### Scenario: Обложкой можно назначить только фото этого альбома
- **WHEN** в качестве обложки передан id фото, не принадлежащего альбому
- **THEN** система отклоняет запрос и не меняет обложку
## ADDED Requirements
### Requirement: Обложка альбома в списке
Система SHALL включать в список альбомов URL миниатюры обложки для каждого альбома, у которого обложка задана.
#### Scenario: Список содержит URL обложки
- **WHEN** пользователь запрашивает список альбомов и у альбома задана обложка
- **THEN** система возвращает для этого альбома URL миниатюры обложки
#### Scenario: Обложка не задана
- **WHEN** у альбома обложка не задана
- **THEN** система возвращает пустое значение обложки для этого альбома
### Requirement: Автоматическая обложка
Система SHALL назначать первое обработанное фото альбома обложкой только если обложка ещё не задана.
#### Scenario: Назначение при пустой обложке
- **WHEN** фото альбома обработано, и у альбома обложка не задана
- **THEN** система назначает это фото обложкой альбома
#### Scenario: Ручной выбор не затирается
- **WHEN** фото альбома обработано, а обложка уже задана
- **THEN** система не изменяет текущую обложку
### Requirement: Удаление фото-обложки
Система SHALL очищать обложку альбома, если фото, назначенное обложкой, удалено.
#### Scenario: Удалено фото-обложка
- **WHEN** удаляется фото, назначенное обложкой альбома
- **THEN** система очищает обложку альбома и список не возвращает URL для неё
+32
View File
@@ -0,0 +1,32 @@
# Tasks
## 1. Обложки — бэкенд
- [x] 1.1 Добавить в `internal/models/models.go` поле `CoverURL string` с тегом `json:"cover_url,omitempty"` у `Album`; убедиться, что `cd backend && go build ./...` проходит
- [x] 1.2 Расширить выборки `internal/albums/store.go` (`ListForUser`, `GetByID`) подзапросом к `photos` за `thumb_key` по `cover_photo_id`; проверить сборку и что при отсутствующем фото ключ пустой
- [x] 1.3 В `internal/albums/service.go` presign получить thumb обложки через существующий `storage.Client` и проставить `CoverURL` в `List` и `Get`; проверить, что `GET /albums` возвращает `cover_url` для альбома с обложкой
- [x] 1.4 Сделать `photos.Store.SetCover` условным (`... WHERE id = $1 AND cover_photo_id IS NULL`), чтобы не затирать ручной выбор; проверить сборку и покрыть тестом из 1.6
- [x] 1.5 В `internal/albums/service.go` метод `Update`: при заданном `CoverPhotoID` отклонять id фото не из этого альбома (`BadRequest`), обложку не менять; проверить сборку
- [x] 1.6 В `internal/photos/service.go` метод `Delete`: если удаляемое фото совпадает с обложкой альбома, очистить `cover_photo_id`; проверить сборку
- [x] 1.7 Добавить/обновить тесты на авто-обложку (не затирает), отклонение чужого фото-обложки и очистку при удалении; `make test` проходит (интеграционные тесты репозиториев — с `TEST_DATABASE_URL`)
## 2. Обложки — список альбомов
- [x] 2.1 Добавить `cover_url?: string | null` в интерфейс `Album` в `frontend/src/lib/types.ts`; `cd frontend && npm run check` без ошибок
- [x] 2.2 В `frontend/src/routes/+page.svelte` в карточке показывать изображение по `cover_url` (aspect-video, object-cover), при отсутствии — прежнюю заглушку; проверить `npm run build` и визуально, что карточка с обложкой показывает фото
## 3. Обложки — выбор в настройках
- [x] 3.1 В `frontend/src/routes/albums/[id]/settings/+page.svelte` добавить секцию «Обложка»: загрузить фото альбома (`GET /albums/:id/photos`) и показать сетку миниатюр; проверить `npm run check`
- [x] 3.2 Клик по миниатюре применяет обложку (`PATCH /albums/:id` с `cover_photo_id`), кнопка «Убрать» вызывает `clear_cover`; обновлять локальное состояние без перезагрузки; проверить визуально, что выбранная обложка помечена и появляется в списке
- [x] 3.3 Секция видна только владельцу/админу (`role`); проверить, что у участника без прав её нет
## 4. Создание альбома — иконка и диалог
- [x] 4.1 Добавить минимальный inline-SVG компонент иконки (например `frontend/src/lib/components/Icon.svelte`) с поддержкой `class`, `aria-label`/`title`; проверить `npm run check`
- [x] 4.2 В `frontend/src/routes/+page.svelte` заменить inline-форму на кнопку-иконку с хинтом (`title` + `aria-label`), открывающую нативный `<dialog>` с полем «Название»; отправка создаёт альбом через существующий `POST /albums` и закрывает диалог; проверить `npm run build` и визуально: создание через диалог добавляет альбом в список
## 5. Интеграция
- [x] 5.1 Выполнить `make build` (фронтенд -> `backend/static` -> бинарник) и убедиться, что сборка проходит без ошибок
- [x] 5.2 Запустить сервер (`make run`) и вручную проверить сквозной сценарий: создание через диалог, загрузка первого фото назначает обложку, ручной выбор не затирается следующей загрузкой, удаление фото-обложки очищает обложку в списке