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