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
+28 -2
View File
@@ -51,7 +51,24 @@ 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)
out, err := s.store.ListForUser(ctx, userID)
if err != nil {
return nil, err
}
for i := range out {
s.attachCover(ctx, &out[i])
}
return out, nil
}
// attachCover fills CoverThumbURL from the presigned thumbnail key, when present.
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
return
}
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
a.CoverThumbURL = url
}
}
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
@@ -76,7 +93,12 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
}
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
return s.Access(ctx, albumID, userID)
a, role, err := s.Access(ctx, albumID, userID)
if err != nil {
return nil, "", err
}
s.attachCover(ctx, a)
return a, role, nil
}
type UpdateInput struct {
@@ -112,6 +134,10 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
if err := s.store.Update(ctx, a); err != nil {
return nil, err
}
if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil {
a = refreshed
}
s.attachCover(ctx, a)
s.hub.Publish(albumID, "album.updated", a)
return a, nil
}
+14 -5
View File
@@ -43,9 +43,14 @@ 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, a.created_at, a.updated_at,
COALESCE(p.thumb_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN photos p ON p.id = a.cover_photo_id
WHERE a.id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -57,9 +62,13 @@ 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, a.created_at, a.updated_at,
COALESCE(p.thumb_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
LEFT JOIN photos p ON p.id = a.cover_photo_id
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
@@ -70,7 +79,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.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
return nil, err
}
out = append(out, a)
+11 -7
View File
@@ -26,13 +26,17 @@ 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:"-"`
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
PhotosCount int `json:"photos_count"`
MembersCount int `json:"members_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AlbumMember struct {
+1 -1
View File
@@ -121,7 +121,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) {
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
return
}
s.store.SetCover(ctx, albumID, photoID)
s.store.SetCoverIfEmpty(ctx, albumID, photoID)
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(albumID, "photo.created", p)
+4 -2
View File
@@ -122,6 +122,8 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil
}
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)
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID)
}
+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()
@@ -0,0 +1 @@
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk;
@@ -0,0 +1,9 @@
-- Clear any dangling cover references before adding the constraint.
UPDATE albums
SET cover_photo_id = NULL
WHERE cover_photo_id IS NOT NULL
AND cover_photo_id NOT IN (SELECT id FROM photos);
ALTER TABLE albums
ADD CONSTRAINT albums_cover_photo_fk
FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL;