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:
iu
2026-10-10 22:13:17 +03:00
parent 78996bc053
commit 2d6a3fea1a
27 changed files with 1097 additions and 85 deletions
+7
View File
@@ -137,6 +137,13 @@ func (s *Server) Router() *gin.Engine {
authed.PATCH("/photos/:id", photosHandler.Rename)
authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/restore", photosHandler.Restore)
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
authed.GET("/trash", photosHandler.Trash)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
+2
View File
@@ -78,6 +78,8 @@ type Photo struct {
Longitude *float64 `json:"longitude,omitempty"`
Caption string `json:"caption"`
EXIF map[string]any `json:"exif,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
AlbumTitle string `json:"album_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
Uploader PublicUser `json:"uploader"`
LikesCount int `json:"likes_count"`
+73
View File
@@ -133,3 +133,76 @@ func (h *Handler) Delete(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
func (h *Handler) PermanentDelete(c *gin.Context) {
if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) Restore(c *gin.Context) {
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) Trash(c *gin.Context) {
list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": list})
}
type batchRequest struct {
IDs []string `json:"ids" binding:"required"`
AlbumID string `json:"album_id"`
}
func (h *Handler) DeleteBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required"))
return
}
n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
func (h *Handler) MoveBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
return
}
n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"moved": n})
}
func (h *Handler) CopyBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
return
}
created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": created})
}
+186 -11
View File
@@ -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
}
+104 -4
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
@@ -28,7 +29,7 @@ const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
COALESCE(p.exif, '{}'::jsonb)::text`
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
@@ -38,7 +39,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText)
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -103,6 +104,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.album_id = $1
AND p.deleted_at IS NULL
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
AND ($4::timestamptz IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC
@@ -122,7 +124,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText); err != nil {
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt); err != nil {
return nil, err
}
setExif(&p, exifText)
@@ -131,7 +133,62 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
return out, rows.Err()
}
func (s *Store) Delete(ctx context.Context, id string) error {
func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
var tag pgconn.CommandTag
var err error
if deleted {
tag, err = s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
} else {
tag, err = s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
}
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Clone creates a new photo row that references the same stored objects as src.
func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
exifJSON, err := json.Marshal(src.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
_, err = s.pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
return err
}
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
return n, err
}
// HardDelete permanently removes the photo row.
func (s *Store) HardDelete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
@@ -142,6 +199,49 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil
}
// ListTrash returns soft-deleted photos from albums the user belongs to.
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`, a.title
FROM photos p
JOIN users u ON u.id = p.uploader_id
JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))
ORDER BY p.deleted_at DESC
LIMIT 200`,
userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle); err != nil {
return nil, err
}
setExif(&p, exifText)
out = append(out, p)
}
return out, rows.Err()
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
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`,