From 9329ebc0238ead187a1ad66301f12b468ef15c6d Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 14:21:43 +0300 Subject: [PATCH] commit --- backend/internal/albums/service.go | 40 ++++++- backend/internal/albums/store.go | 14 ++- backend/internal/app/app.go | 4 +- backend/internal/models/models.go | 16 +-- backend/internal/photos/service.go | 1 + backend/internal/photos/store.go | 13 ++- backend/internal/repo/repo_test.go | 92 ++++++++++++++++ frontend/src/lib/components/Icon.svelte | 29 +++++ frontend/src/lib/types.ts | 1 + frontend/src/routes/+page.svelte | 70 ++++++++++-- frontend/src/routes/albums/[id]/+page.svelte | 2 +- .../routes/albums/[id]/settings/+page.svelte | 89 ++++++++++++++- .../changes/album-ui-covers/.openspec.yaml | 2 + openspec/changes/album-ui-covers/design.md | 102 ++++++++++++++++++ openspec/changes/album-ui-covers/proposal.md | 50 +++++++++ .../album-ui-covers/specs/albums/spec.md | 68 ++++++++++++ openspec/changes/album-ui-covers/tasks.md | 32 ++++++ 17 files changed, 591 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/components/Icon.svelte create mode 100644 openspec/changes/album-ui-covers/.openspec.yaml create mode 100644 openspec/changes/album-ui-covers/design.md create mode 100644 openspec/changes/album-ui-covers/proposal.md create mode 100644 openspec/changes/album-ui-covers/specs/albums/spec.md create mode 100644 openspec/changes/album-ui-covers/tasks.md diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index 168b0f4..27959a5 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -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 } diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go index 3d947d8..51a9521 100644 --- a/backend/internal/albums/store.go +++ b/backend/internal/albums/store.go @@ -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) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7330727..7d723c7 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 7456174..f0f5d7a 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -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 { diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 7696dce..4a46fed 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -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) diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 8937da9..f495933 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -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 } diff --git a/backend/internal/repo/repo_test.go b/backend/internal/repo/repo_test.go index 14d23df..72e1e09 100644 --- a/backend/internal/repo/repo_test.go +++ b/backend/internal/repo/repo_test.go @@ -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() diff --git a/frontend/src/lib/components/Icon.svelte b/frontend/src/lib/components/Icon.svelte new file mode 100644 index 0000000..f8a86bb --- /dev/null +++ b/frontend/src/lib/components/Icon.svelte @@ -0,0 +1,29 @@ + + + + {#if path}{/if} + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0f00e5d..2daceed 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6906b20..74884bf 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,12 +1,20 @@