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
+48 -1
View File
@@ -143,7 +143,11 @@ func (h *Handler) PermanentDelete(c *gin.Context) {
}
func (h *Handler) Restore(c *gin.Context) {
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c))
var req struct {
AlbumID string `json:"album_id"`
}
_ = c.ShouldBindJSON(&req)
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
@@ -160,6 +164,49 @@ func (h *Handler) Trash(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photos": list})
}
func (h *Handler) TrashCount(c *gin.Context) {
n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"count": n})
}
func (h *Handler) Tags(c *gin.Context) {
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
func (h *Handler) AddTag(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
return
}
p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) RemoveTag(c *gin.Context) {
p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
type batchRequest struct {
IDs []string `json:"ids" binding:"required"`
AlbumID string `json:"album_id"`
+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)
}
+140 -13
View File
@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
"shared-album/internal/models"
)
@@ -21,7 +23,7 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.mime_type,
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
@@ -29,17 +31,20 @@ 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, p.deleted_at`
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at,
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
var exifText string
var exifText, tagsText string
err := row.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.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -47,9 +52,20 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
return &p, nil
}
func setTags(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "[]" {
return
}
var tags []models.Tag
if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
p.Tags = tags
}
}
func setExif(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "{}" {
return
@@ -118,16 +134,17 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText string
var exifText, tagsText 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); err != nil {
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
@@ -199,16 +216,20 @@ func (s *Store) HardDelete(ctx context.Context, id string) error {
return nil
}
// ListTrash returns soft-deleted photos from albums the user belongs to.
// ListTrash returns soft-deleted photos from albums the user belongs to, plus
// their own photos whose album no longer exists.
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`, a.title
`SELECT `+photoColumns+`, COALESCE(a.title, '')
FROM photos p
JOIN users u ON u.id = p.uploader_id
JOIN albums a ON a.id = p.album_id
LEFT 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))
AND (
(a.id 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)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)
ORDER BY p.deleted_at DESC
LIMIT 200`,
userID, userID)
@@ -220,21 +241,127 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText string
var exifText, tagsText 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 {
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
// TrashCount counts soft-deleted photos accessible to the user.
func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
var n int
err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM photos p
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (
(a.id 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)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)`, userID).Scan(&n)
return n, err
}
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AlbumExists reports whether an album row exists.
func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
return ok, err
}
func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, name FROM tags
WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $2 || '%')
ORDER BY lower(name) LIMIT 50`, userID, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Tag{}
for rows.Next() {
var t models.Tag
if err := rows.Scan(&t.ID, &t.Name); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// EnsureTag returns the user's tag with the given name, creating it if needed.
func (s *Store) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, ErrNotFound
}
var t models.Tag
err := s.pool.QueryRow(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name)
if err == nil {
return &t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
id := auth.NewID()
err = s.pool.QueryRow(ctx,
`INSERT INTO tags (id, user_id, name) VALUES ($1,$2,$3) RETURNING id, name`, id, userID, name).
Scan(&t.ID, &t.Name)
if err != nil {
if e2 := s.pool.QueryRow(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name); e2 == nil {
return &t, nil
}
return nil, err
}
return &t, nil
}
func (s *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
return err
}
func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
return err
}
func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
return ok, err
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil