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