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:
iu
2026-10-10 18:15:13 +03:00
parent b90e6b0267
commit 304cfedb75
47 changed files with 1942 additions and 601 deletions
+66
View File
@@ -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()