commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-10
|
||||
@@ -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 для неё
|
||||
@@ -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`) и вручную проверить сквозной сценарий: создание через диалог, загрузка первого фото назначает обложку, ручной выбор не затирается следующей загрузкой, удаление фото-обложки очищает обложку в списке
|
||||
Reference in New Issue
Block a user