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
+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