feat: lightroom-style workspace UI with album covers
Backend: - expose cover thumbnail URL on album responses (LEFT JOIN + presign) - auto-assign cover only when empty; FK cover_photo_id -> photos ON DELETE SET NULL - add photos_count / members_count to album responses; cover repo test Frontend: - split layouts into (app) shell (resizable rail + contextual panel) and (public) - album rail + create-album dialog; album panel with cover picker / make-cover - photo viewer route with filmstrip, replacing lightbox; share reuses it - grid density slider, toolbar + drag-and-drop upload, dark graphite default OpenSpec: add lightroom-workspace-ui change (proposal, design, specs, tasks)
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/sharelinks"
|
||||
)
|
||||
|
||||
@@ -123,6 +124,71 @@ func TestAlbumRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumCoverRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
store := albums.NewStore(pool)
|
||||
|
||||
owner := newUser(t, ctx, pool, "CoverOwner")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
|
||||
|
||||
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"}
|
||||
if err := store.Create(ctx, a); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
insertPhoto := func(id, thumbKey string) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
id, a.ID, owner.ID, "albums/"+a.ID+"/original/"+id+".jpg", thumbKey); err != nil {
|
||||
t.Fatalf("insert photo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
photo1 := auth.NewID()
|
||||
insertPhoto(photo1, "albums/"+a.ID+"/thumb/"+photo1+".webp")
|
||||
|
||||
got, err := store.GetByID(ctx, a.ID)
|
||||
if err != nil || got.CoverThumbKey != "" {
|
||||
t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey)
|
||||
}
|
||||
|
||||
ps := photos.NewStore(pool)
|
||||
ps.SetCoverIfEmpty(ctx, a.ID, photo1)
|
||||
got, _ = store.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
|
||||
t.Fatalf("cover not set: %+v", got.CoverPhotoID)
|
||||
}
|
||||
if want := "albums/" + a.ID + "/thumb/" + photo1 + ".webp"; got.CoverThumbKey != want {
|
||||
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, want)
|
||||
}
|
||||
|
||||
photo2 := auth.NewID()
|
||||
insertPhoto(photo2, "thumb-2")
|
||||
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
|
||||
got, _ = store.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
|
||||
t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil {
|
||||
t.Fatalf("delete cover photo: %v", err)
|
||||
}
|
||||
got, _ = store.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
|
||||
list, err := store.ListForUser(ctx, owner.ID)
|
||||
if err != nil || len(list) != 1 || list[0].CoverThumbKey != "thumb-2" {
|
||||
t.Fatalf("list cover key: %v %+v", err, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
Reference in New Issue
Block a user