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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user