feat: context menus, unified photo panel, tags, album trash and counters

- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count
- frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs
- openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
This commit is contained in:
iu
2026-10-10 22:45:41 +03:00
parent 2d6a3fea1a
commit 61b45b2742
37 changed files with 1639 additions and 328 deletions
+109 -15
View File
@@ -339,7 +339,10 @@ func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string)
}
// Restore returns a photo from the trash back to its album.
func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.Photo, error) {
// Restore returns a photo from the trash back to an album. When targetAlbumID is
// empty the photo goes back to its own album; if that album is gone, an
// album_required error is returned so the client can prompt for a target.
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
@@ -347,24 +350,52 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
}
return nil, err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
if targetAlbumID != "" {
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return nil, err
}
if !roles.CanUpload(role) {
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
return nil, err
}
p.AlbumID = targetAlbumID
p.DeletedAt = nil
} else {
if p.AlbumID == "" {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
if err != nil {
return nil, err
}
if !exists {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
}
if p.DeletedAt != nil {
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
return nil, err
}
}
p.DeletedAt = nil
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
}
if p.DeletedAt == nil {
return p, nil
}
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
return nil, err
}
p.DeletedAt = nil
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
s.hub.Publish(p.AlbumID, "photo.created", p)
if p.AlbumID != "" {
s.hub.Publish(p.AlbumID, "photo.created", p)
}
return p, nil
}
@@ -492,3 +523,66 @@ func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, err
}
return out, nil
}
// TrashCount returns the number of photos in the user's trash.
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
return s.store.TrashCount(ctx, userID)
}
// Tags lists the user's tags, optionally filtered by a search query.
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
return s.store.ListTags(ctx, userID, q)
}
func (s *Service) taggablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if p.AlbumID == "" {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
return p, nil
}
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
tag, err := s.store.EnsureTag(ctx, userID, name)
if err != nil {
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
}
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
// RemoveTag detaches one of the user's tags from a photo.
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
if err != nil {
return nil, err
}
if !owned {
return nil, httpx.NotFound("tag_not_found", "tag not found")
}
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}