Author SHA1 Message Date
iu 9329ebc023 commit 2026-10-10 14:21:43 +03:00
17 changed files with 591 additions and 34 deletions
+37 -3
View File
@@ -17,15 +17,20 @@ type MemberLookup interface {
Role(ctx context.Context, albumID, userID string) (string, error)
}
type PhotoLookup interface {
PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error)
}
type Service struct {
store *Store
members MemberLookup
photos PhotoLookup
hub *sse.Hub
storage *storage.Client
}
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *Service {
return &Service{store: store, members: members, hub: hub, storage: storage}
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client, photos PhotoLookup) *Service {
return &Service{store: store, members: members, photos: photos, hub: hub, storage: storage}
}
type CreateInput struct {
@@ -51,7 +56,23 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
}
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
return s.store.ListForUser(ctx, userID)
list, err := s.store.ListForUser(ctx, userID)
if err != nil {
return nil, err
}
for i := range list {
s.attachCoverURL(ctx, &list[i])
}
return list, nil
}
func (s *Service) attachCoverURL(ctx context.Context, a *models.Album) {
if a == nil || a.CoverThumbKey == "" || s.storage == nil {
return
}
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
a.CoverURL = url
}
}
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
@@ -62,6 +83,7 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
}
return nil, "", err
}
s.attachCoverURL(ctx, a)
if a.OwnerID == userID {
return a, roles.Owner, nil
}
@@ -107,11 +129,23 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
if in.ClearCover {
a.CoverPhotoID = nil
} else if in.CoverPhotoID != nil {
ok, err := s.photos.PhotoInAlbum(ctx, *in.CoverPhotoID, albumID)
if err != nil {
return nil, err
}
if !ok {
return nil, httpx.BadRequest("invalid_cover", "cover photo must belong to the album")
}
a.CoverPhotoID = in.CoverPhotoID
}
if err := s.store.Update(ctx, a); err != nil {
return nil, err
}
if fresh, err := s.store.GetByID(ctx, albumID); err == nil {
a.CoverThumbKey = fresh.CoverThumbKey
a.CoverURL = ""
s.attachCoverURL(ctx, a)
}
s.hub.Publish(albumID, "album.updated", a)
return a, nil
}
+9 -5
View File
@@ -43,9 +43,11 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
var a models.Album
err := s.pool.QueryRow(ctx,
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
FROM albums WHERE id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
a.created_at, a.updated_at
FROM albums a WHERE a.id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -57,7 +59,9 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.pool.Query(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
a.created_at, a.updated_at
FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
@@ -70,7 +74,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
out := []models.Album{}
for rows.Next() {
var a models.Album
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil {
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
}
out = append(out, a)
+2 -2
View File
@@ -52,14 +52,14 @@ func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hu
albumsStore := albums.NewStore(pool)
membersStore := members.NewStore(pool)
photosStore := photos.NewStore(pool)
notifStore := notifications.NewStore(pool)
notifSvc := notifications.NewService(notifStore, hub)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st, photosStore)
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
photosStore := photos.NewStore(pool)
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
socialStore := social.NewStore(pool)
+9 -7
View File
@@ -26,13 +26,15 @@ func (u *User) Public() PublicUser {
}
type Album struct {
ID string `json:"id"`
OwnerID string `json:"owner_id"`
Title string `json:"title"`
Description string `json:"description"`
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
OwnerID string `json:"owner_id"`
Title string `json:"title"`
Description string `json:"description"`
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
CoverThumbKey string `json:"-"`
CoverURL string `json:"cover_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AlbumMember struct {
+1
View File
@@ -269,6 +269,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
if err := s.store.Delete(ctx, photoID); err != nil {
return err
}
s.store.ClearCover(ctx, p.AlbumID, p.ID)
keys := []string{p.StorageKey}
if p.ThumbKey != "" {
keys = append(keys, p.ThumbKey)
+12 -1
View File
@@ -123,5 +123,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
}
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, albumID, photoID)
}
func (s *Store) ClearCover(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`, albumID, photoID)
}
func (s *Store) PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM photos WHERE id = $1 AND album_id = $2)`, photoID, albumID).Scan(&exists)
return exists, err
}
+92
View File
@@ -12,7 +12,9 @@ import (
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/sse"
)
func testPool(t *testing.T) *pgxpool.Pool {
@@ -187,6 +189,96 @@ func TestMemberRepository(t *testing.T) {
}
}
func TestAlbumCover(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
albumStore := albums.NewStore(pool)
photosStore := photos.NewStore(pool)
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
if err := albumStore.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
p1 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k1", Width: 1, Height: 1}
p2 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k2", Width: 1, Height: 1}
for _, p := range []*models.Photo{p1, p2} {
if err := photosStore.Create(ctx, p); err != nil {
t.Fatalf("create photo: %v", err)
}
}
photosStore.SetCover(ctx, a.ID, p1.ID)
got, _ := albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
t.Fatalf("auto-cover not set: %v", got.CoverPhotoID)
}
photosStore.SetCover(ctx, a.ID, p2.ID)
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
t.Fatalf("auto-cover overwrote existing cover: %v", got.CoverPhotoID)
}
photosStore.ClearCover(ctx, a.ID, p2.ID)
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil {
t.Fatalf("clear with non-cover photo should be a no-op")
}
photosStore.ClearCover(ctx, a.ID, p1.ID)
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared: %v", got.CoverPhotoID)
}
if ok, err := photosStore.PhotoInAlbum(ctx, p1.ID, a.ID); err != nil || !ok {
t.Fatalf("photo should belong to album: ok=%v err=%v", ok, err)
}
other := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "B"}
if err := albumStore.Create(ctx, other); err != nil {
t.Fatalf("create other album: %v", err)
}
pForeign := &models.Photo{ID: auth.NewID(), AlbumID: other.ID, UploaderID: owner.ID, StorageKey: "k3", Width: 1, Height: 1}
if err := photosStore.Create(ctx, pForeign); err != nil {
t.Fatalf("create foreign photo: %v", err)
}
if ok, _ := photosStore.PhotoInAlbum(ctx, pForeign.ID, a.ID); ok {
t.Fatalf("foreign photo should not belong to album")
}
svc := albums.NewService(albumStore, members.NewStore(pool), sse.NewHub(16), nil, photosStore)
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &pForeign.ID}); err == nil {
t.Fatalf("expected error when setting foreign photo as cover")
}
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("foreign cover should not be applied: %v", got.CoverPhotoID)
}
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &p2.ID}); err != nil {
t.Fatalf("valid cover update: %v", err)
}
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != p2.ID {
t.Fatalf("valid cover not applied: %v", got.CoverPhotoID)
}
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{ClearCover: true}); err != nil {
t.Fatalf("clear cover via service: %v", err)
}
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared via service: %v", got.CoverPhotoID)
}
}
func TestShareLinkRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
interface Props {
name: string;
class?: string;
label?: string;
}
let { name, class: className = 'size-5', label }: Props = $props();
const icons: Record<string, string> = {
add: 'M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z',
close:
'M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z'
};
const path = $derived(icons[name] ?? '');
</script>
<svg
viewBox="0 0 24 24"
fill="currentColor"
focusable="false"
class={className}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
>
{#if path}<path d={path} />{/if}
</svg>
+1
View File
@@ -11,6 +11,7 @@ export interface Album {
title: string;
description: string;
cover_photo_id?: string | null;
cover_url?: string | null;
created_at: string;
updated_at: string;
}
+59 -11
View File
@@ -1,12 +1,20 @@
<script lang="ts">
import { api } from '$lib/api';
import { auth } from '$lib/stores/auth.svelte';
import Icon from '$lib/components/Icon.svelte';
import type { Album } from '$lib/types';
let albums = $state<Album[]>([]);
let loading = $state(true);
let title = $state('');
let error = $state('');
let dialogEl: HTMLDialogElement | undefined = $state();
function openDialog() {
title = '';
error = '';
dialogEl?.showModal();
}
async function load() {
if (!auth.isAuthenticated) {
@@ -37,6 +45,7 @@
});
albums = [data.album, ...albums];
title = '';
dialogEl?.close();
} catch (e) {
error = (e as Error).message;
}
@@ -52,16 +61,15 @@
{:else}
<div class="mb-6 flex items-center justify-between gap-4">
<h1 class="text-2xl font-bold">Мои альбомы</h1>
<form class="flex gap-2" onsubmit={create}>
<input
bind:value={title}
placeholder="Название альбома"
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
/>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
>Создать</button
>
</form>
<button
type="button"
onclick={openDialog}
title="Создать альбом"
aria-label="Создать альбом"
class="bg-primary text-primary-fg rounded-lg p-2"
>
<Icon name="add" class="size-5" />
</button>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
@@ -78,7 +86,19 @@
href="/albums/{album.id}"
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
>
<div class="bg-bg-muted flex aspect-video items-center justify-center text-4xl">🖼️</div>
{#if album.cover_url}
<img
src={album.cover_url}
alt={album.title}
loading="lazy"
decoding="async"
class="bg-bg-muted aspect-video w-full object-cover"
/>
{:else}
<div class="bg-bg-muted flex aspect-video items-center justify-center text-4xl">
🖼️
</div>
{/if}
<div class="p-4">
<h2 class="text-text font-semibold">{album.title}</h2>
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
@@ -88,4 +108,32 @@
{/each}
</ul>
{/if}
<dialog
bind:this={dialogEl}
class="bg-bg-elevated text-text border-border m-auto w-full max-w-sm rounded-xl border p-0 backdrop:bg-black/50"
>
<form class="space-y-4 p-5" onsubmit={create}>
<h2 class="text-lg font-semibold">Новый альбом</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Название</span>
<input
bind:value={title}
placeholder="Название альбома"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
onclick={() => dialogEl?.close()}>Отмена</button
>
<button type="submit" class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm"
>Создать</button
>
</div>
</form>
</dialog>
{/if}
+1 -1
View File
@@ -8,7 +8,7 @@
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id);
const albumId = $derived($page.params.id ?? '');
let album = $state<Album | null>(null);
let role = $state('');
@@ -2,11 +2,13 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import type { Album, ShareLink } from '$lib/types';
import type { Album, Photo, ShareLink } from '$lib/types';
const albumId = $derived($page.params.id);
let album = $state<Album | null>(null);
let role = $state('');
let photos = $state<Photo[]>([]);
let shareLinks = $state<ShareLink[]>([]);
let title = $state('');
let description = $state('');
@@ -16,14 +18,17 @@
async function load() {
try {
const [albumData, linksData] = await Promise.all([
api<{ album: Album }>(`/albums/${albumId}`),
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`)
const [albumData, linksData, photosData] = await Promise.all([
api<{ album: Album; role: string }>(`/albums/${albumId}`),
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`),
api<{ photos: Photo[]; next_cursor: string }>(`/albums/${albumId}/photos`)
]);
album = albumData.album;
role = albumData.role;
title = album.title;
description = album.description;
shareLinks = linksData.share_links;
photos = photosData.photos;
} catch (e) {
error = (e as Error).message;
}
@@ -49,6 +54,32 @@
}
}
async function setCover(photoId: string) {
error = '';
try {
const data = await api<{ album: Album }>(`/albums/${albumId}`, {
method: 'PATCH',
body: { cover_photo_id: photoId }
});
album = data.album;
} catch (e) {
error = (e as Error).message;
}
}
async function clearCover() {
error = '';
try {
const data = await api<{ album: Album }>(`/albums/${albumId}`, {
method: 'PATCH',
body: { clear_cover: true }
});
album = data.album;
} catch (e) {
error = (e as Error).message;
}
}
async function createLink(ev: Event) {
ev.preventDefault();
try {
@@ -107,6 +138,56 @@
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
</form>
{#if role === 'owner' || role === 'admin'}
<div class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Обложка</h2>
{#if album?.cover_photo_id}
<button type="button" class="text-danger text-sm" onclick={clearCover}>Убрать</button>
{/if}
</div>
{#if photos.length === 0}
<p class="text-text-muted text-sm">В альбоме пока нет фото.</p>
{:else}
<ul class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{#each photos as photo (photo.id)}
<li>
<button
type="button"
onclick={() => setCover(photo.id)}
title="Сделать обложкой"
aria-pressed={album?.cover_photo_id === photo.id}
class="group relative block aspect-square w-full overflow-hidden rounded-lg border-2 {album?.cover_photo_id ===
photo.id
? 'border-primary'
: 'border-border'}"
>
{#if photo.thumb_url}
<img
src={photo.thumb_url}
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
{:else}
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
обработка…
</span>
{/if}
{#if album?.cover_photo_id === photo.id}
<span class="bg-primary text-primary-fg absolute right-1 bottom-1 rounded px-1.5 py-0.5 text-xs"
>✓</span
>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<div class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4">
<h2 class="font-semibold">Ссылки для просмотра</h2>
<form class="flex items-end gap-2" onsubmit={createLink}>
@@ -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`) и вручную проверить сквозной сценарий: создание через диалог, загрузка первого фото назначает обложку, ручной выбор не затирается следующей загрузкой, удаление фото-обложки очищает обложку в списке