feat: photo multiselect, move/copy, trash and compact chrome
- backend: soft delete + trash (photos.deleted_at, migration 000005), GET /trash, restore, permanent delete with storage-key refcount (S3 objects removed only when no other album references them); batch endpoints photo-batch/{delete,move,copy} - metadata only, copies clone rows sharing storage keys
- frontend: Ctrl/Shift multiselect; right-panel 'Действия' as icon text rows (Открыть (просмотр), Переместить, Копировать, Удалить); drag selected photos onto rail album -> TransferDialog; /trash route; trash entry as last item in the left rail; empty right panel hidden on overview
- frontend: compact top bars (app header and viewer), compact theme switcher
- openspec: sync lightroom-workspace-ui artifacts (photos/ui-workspace specs, design #20-24, tasks 9.11-9.17)
This commit is contained in:
@@ -207,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if p.DeletedAt != nil {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -230,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if p.DeletedAt != nil {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,23 +303,192 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
own := p.UploaderID == userID
|
||||
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||
if p.DeletedAt != nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
keys = append(keys, p.PreviewKey)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, keys...)
|
||||
s.publishDeleted(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func canDeletePhoto(role string, own bool) bool {
|
||||
return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))
|
||||
}
|
||||
|
||||
func (s *Service) publishDeleted(p *models.Photo) {
|
||||
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
|
||||
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete.
|
||||
func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) {
|
||||
deleted := 0
|
||||
for _, id := range ids {
|
||||
if err := s.Delete(ctx, id, userID); err != nil {
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// Restore returns a photo from the trash back to its album.
|
||||
func (s *Service) Restore(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
|
||||
}
|
||||
_, 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 {
|
||||
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)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// PermanentDelete removes the photo row; stored objects are deleted only when no
|
||||
// other album references the same storage key.
|
||||
func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
if err := s.store.HardDelete(ctx, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.AlbumID != "" {
|
||||
s.publishDeleted(p)
|
||||
}
|
||||
if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 {
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
keys = append(keys, p.PreviewKey)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, keys...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveBatch moves photos to another album (metadata only; stored objects untouched).
|
||||
func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
|
||||
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !roles.CanUpload(targetRole) {
|
||||
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||
}
|
||||
moved := 0
|
||||
for _, id := range ids {
|
||||
p, err := s.store.GetByID(ctx, id, userID)
|
||||
if err != nil || p.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
oldAlbum := p.AlbumID
|
||||
if oldAlbum == targetAlbumID {
|
||||
continue
|
||||
}
|
||||
_, srcRole, err := s.albums.Access(ctx, oldAlbum, userID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !canDeletePhoto(srcRole, p.UploaderID == userID) {
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
|
||||
continue
|
||||
}
|
||||
p.AlbumID = targetAlbumID
|
||||
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
|
||||
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
|
||||
})
|
||||
if err := s.attachURLs(ctx, p); err == nil {
|
||||
s.hub.Publish(targetAlbumID, "photo.created", p)
|
||||
}
|
||||
moved++
|
||||
}
|
||||
return moved, nil
|
||||
}
|
||||
|
||||
// CopyBatch clones photos into another album (metadata only; stored objects shared).
|
||||
func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) {
|
||||
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanUpload(targetRole) {
|
||||
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||
}
|
||||
out := []models.Photo{}
|
||||
for _, id := range ids {
|
||||
src, err := s.store.GetByID(ctx, id, userID)
|
||||
if err != nil || src.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil {
|
||||
continue
|
||||
}
|
||||
newID := auth.NewID()
|
||||
if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil {
|
||||
continue
|
||||
}
|
||||
clone, err := s.store.GetByID(ctx, newID, userID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.attachURLs(ctx, clone); err != nil {
|
||||
continue
|
||||
}
|
||||
s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID)
|
||||
s.hub.Publish(targetAlbumID, "photo.created", clone)
|
||||
out = append(out, *clone)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Trash lists soft-deleted photos from all albums the user belongs to.
|
||||
func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||
out, err := s.store.ListTrash(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
_ = s.attachURLs(ctx, &out[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user