commit
This commit is contained in:
@@ -17,15 +17,20 @@ type MemberLookup interface {
|
|||||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PhotoLookup interface {
|
||||||
|
PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
members MemberLookup
|
members MemberLookup
|
||||||
|
photos PhotoLookup
|
||||||
hub *sse.Hub
|
hub *sse.Hub
|
||||||
storage *storage.Client
|
storage *storage.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *Service {
|
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client, photos PhotoLookup) *Service {
|
||||||
return &Service{store: store, members: members, hub: hub, storage: storage}
|
return &Service{store: store, members: members, photos: photos, hub: hub, storage: storage}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateInput struct {
|
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) {
|
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) {
|
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
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
s.attachCoverURL(ctx, a)
|
||||||
if a.OwnerID == userID {
|
if a.OwnerID == userID {
|
||||||
return a, roles.Owner, nil
|
return a, roles.Owner, nil
|
||||||
}
|
}
|
||||||
@@ -107,11 +129,23 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
|||||||
if in.ClearCover {
|
if in.ClearCover {
|
||||||
a.CoverPhotoID = nil
|
a.CoverPhotoID = nil
|
||||||
} else if in.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
|
a.CoverPhotoID = in.CoverPhotoID
|
||||||
}
|
}
|
||||||
if err := s.store.Update(ctx, a); err != nil {
|
if err := s.store.Update(ctx, a); err != nil {
|
||||||
return nil, err
|
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)
|
s.hub.Publish(albumID, "album.updated", a)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||||
var a models.Album
|
var a models.Album
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
|
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||||
FROM albums WHERE id = $1`, id).
|
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
|
||||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
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) {
|
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
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
|
FROM albums a
|
||||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
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
|
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{}
|
out := []models.Album{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.Album
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hu
|
|||||||
|
|
||||||
albumsStore := albums.NewStore(pool)
|
albumsStore := albums.NewStore(pool)
|
||||||
membersStore := members.NewStore(pool)
|
membersStore := members.NewStore(pool)
|
||||||
|
photosStore := photos.NewStore(pool)
|
||||||
|
|
||||||
notifStore := notifications.NewStore(pool)
|
notifStore := notifications.NewStore(pool)
|
||||||
notifSvc := notifications.NewService(notifStore, hub)
|
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)
|
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
|
||||||
|
|
||||||
photosStore := photos.NewStore(pool)
|
|
||||||
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
|
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
|
||||||
|
|
||||||
socialStore := social.NewStore(pool)
|
socialStore := social.NewStore(pool)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type Album struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||||
|
CoverThumbKey string `json:"-"`
|
||||||
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
|||||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
s.store.ClearCover(ctx, p.AlbumID, p.ID)
|
||||||
keys := []string{p.StorageKey}
|
keys := []string{p.StorageKey}
|
||||||
if p.ThumbKey != "" {
|
if p.ThumbKey != "" {
|
||||||
keys = append(keys, p.ThumbKey)
|
keys = append(keys, p.ThumbKey)
|
||||||
|
|||||||
@@ -123,5 +123,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
"shared-album/internal/auth"
|
"shared-album/internal/auth"
|
||||||
"shared-album/internal/members"
|
"shared-album/internal/members"
|
||||||
"shared-album/internal/models"
|
"shared-album/internal/models"
|
||||||
|
"shared-album/internal/photos"
|
||||||
"shared-album/internal/sharelinks"
|
"shared-album/internal/sharelinks"
|
||||||
|
"shared-album/internal/sse"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testPool(t *testing.T) *pgxpool.Pool {
|
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) {
|
func TestShareLinkRepository(t *testing.T) {
|
||||||
pool := testPool(t)
|
pool := testPool(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -11,6 +11,7 @@ export interface Album {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
cover_photo_id?: string | null;
|
cover_photo_id?: string | null;
|
||||||
|
cover_url?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { Album } from '$lib/types';
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
let albums = $state<Album[]>([]);
|
let albums = $state<Album[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
title = '';
|
||||||
|
error = '';
|
||||||
|
dialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated) {
|
||||||
@@ -37,6 +45,7 @@
|
|||||||
});
|
});
|
||||||
albums = [data.album, ...albums];
|
albums = [data.album, ...albums];
|
||||||
title = '';
|
title = '';
|
||||||
|
dialogEl?.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message;
|
error = (e as Error).message;
|
||||||
}
|
}
|
||||||
@@ -52,16 +61,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
<h1 class="text-2xl font-bold">Мои альбомы</h1>
|
<h1 class="text-2xl font-bold">Мои альбомы</h1>
|
||||||
<form class="flex gap-2" onsubmit={create}>
|
<button
|
||||||
<input
|
type="button"
|
||||||
bind:value={title}
|
onclick={openDialog}
|
||||||
placeholder="Название альбома"
|
title="Создать альбом"
|
||||||
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
|
aria-label="Создать альбом"
|
||||||
/>
|
class="bg-primary text-primary-fg rounded-lg p-2"
|
||||||
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
|
|
||||||
>Создать</button
|
|
||||||
>
|
>
|
||||||
</form>
|
<Icon name="add" class="size-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
||||||
@@ -78,7 +86,19 @@
|
|||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
|
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">
|
<div class="p-4">
|
||||||
<h2 class="text-text font-semibold">{album.title}</h2>
|
<h2 class="text-text font-semibold">{album.title}</h2>
|
||||||
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
|
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
|
||||||
@@ -88,4 +108,32 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||||
import type { Album, Photo } from '$lib/types';
|
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 album = $state<Album | null>(null);
|
||||||
let role = $state('');
|
let role = $state('');
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
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);
|
const albumId = $derived($page.params.id);
|
||||||
|
|
||||||
let album = $state<Album | null>(null);
|
let album = $state<Album | null>(null);
|
||||||
|
let role = $state('');
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
let shareLinks = $state<ShareLink[]>([]);
|
let shareLinks = $state<ShareLink[]>([]);
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
@@ -16,14 +18,17 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [albumData, linksData] = await Promise.all([
|
const [albumData, linksData, photosData] = await Promise.all([
|
||||||
api<{ album: Album }>(`/albums/${albumId}`),
|
api<{ album: Album; role: string }>(`/albums/${albumId}`),
|
||||||
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`)
|
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`),
|
||||||
|
api<{ photos: Photo[]; next_cursor: string }>(`/albums/${albumId}/photos`)
|
||||||
]);
|
]);
|
||||||
album = albumData.album;
|
album = albumData.album;
|
||||||
|
role = albumData.role;
|
||||||
title = album.title;
|
title = album.title;
|
||||||
description = album.description;
|
description = album.description;
|
||||||
shareLinks = linksData.share_links;
|
shareLinks = linksData.share_links;
|
||||||
|
photos = photosData.photos;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).message;
|
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) {
|
async function createLink(ev: Event) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -107,6 +138,56 @@
|
|||||||
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
|
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
|
||||||
</form>
|
</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">
|
<div class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4">
|
||||||
<h2 class="font-semibold">Ссылки для просмотра</h2>
|
<h2 class="font-semibold">Ссылки для просмотра</h2>
|
||||||
<form class="flex items-end gap-2" onsubmit={createLink}>
|
<form class="flex items-end gap-2" onsubmit={createLink}>
|
||||||
|
|||||||
@@ -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