diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go
index f77b3c2..79022a1 100644
--- a/backend/internal/albums/service.go
+++ b/backend/internal/albums/service.go
@@ -150,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
if role != roles.Owner {
return httpx.Forbidden("forbidden", "only the owner can delete the album")
}
- if err := s.store.Delete(ctx, a.ID); err != nil {
+ if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
return err
}
- if s.storage != nil {
- _ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
+ if err := s.store.Delete(ctx, a.ID); err != nil {
+ return err
}
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
return nil
diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go
index 1344803..b4cd255 100644
--- a/backend/internal/albums/store.go
+++ b/backend/internal/albums/store.go
@@ -107,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
}
return nil
}
+
+// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
+// or permanently removed from the trash after the album is gone.
+func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
+ _, err := s.pool.Exec(ctx,
+ `UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
+ return err
+}
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index 30ba552..6dfdc8b 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -139,11 +139,15 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/restore", photosHandler.Restore)
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
+ authed.POST("/photos/:id/tags", photosHandler.AddTag)
+ authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
+ authed.GET("/tags", photosHandler.Tags)
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.GET("/trash/count", photosHandler.TrashCount)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go
index d4df5de..c6835dc 100644
--- a/backend/internal/models/models.go
+++ b/backend/internal/models/models.go
@@ -78,6 +78,7 @@ type Photo struct {
Longitude *float64 `json:"longitude,omitempty"`
Caption string `json:"caption"`
EXIF map[string]any `json:"exif,omitempty"`
+ Tags []Tag `json:"tags,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
AlbumTitle string `json:"album_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -91,6 +92,11 @@ type Photo struct {
DownloadURL string `json:"download_url,omitempty"`
}
+type Tag struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
type Comment struct {
ID string `json:"id"`
PhotoID string `json:"photo_id"`
diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go
index 61dd588..c207078 100644
--- a/backend/internal/photos/handlers.go
+++ b/backend/internal/photos/handlers.go
@@ -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"`
diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go
index 1465c76..c58bfc4 100644
--- a/backend/internal/photos/service.go
+++ b/backend/internal/photos/service.go
@@ -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)
+}
diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go
index 2bcf76b..446c4a6 100644
--- a/backend/internal/photos/store.go
+++ b/backend/internal/photos/store.go
@@ -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
diff --git a/backend/migrations/000006_photo_album_nullable.down.sql b/backend/migrations/000006_photo_album_nullable.down.sql
new file mode 100644
index 0000000..9fdcff6
--- /dev/null
+++ b/backend/migrations/000006_photo_album_nullable.down.sql
@@ -0,0 +1,5 @@
+ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
+DELETE FROM photos WHERE album_id IS NULL;
+ALTER TABLE photos ALTER COLUMN album_id SET NOT NULL;
+ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
+ FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE;
diff --git a/backend/migrations/000006_photo_album_nullable.up.sql b/backend/migrations/000006_photo_album_nullable.up.sql
new file mode 100644
index 0000000..371002b
--- /dev/null
+++ b/backend/migrations/000006_photo_album_nullable.up.sql
@@ -0,0 +1,4 @@
+ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
+ALTER TABLE photos ALTER COLUMN album_id DROP NOT NULL;
+ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
+ FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL;
diff --git a/backend/migrations/000007_photo_tags.down.sql b/backend/migrations/000007_photo_tags.down.sql
new file mode 100644
index 0000000..e86d7d8
--- /dev/null
+++ b/backend/migrations/000007_photo_tags.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS photo_tags;
+DROP TABLE IF EXISTS tags;
diff --git a/backend/migrations/000007_photo_tags.up.sql b/backend/migrations/000007_photo_tags.up.sql
new file mode 100644
index 0000000..0b0737f
--- /dev/null
+++ b/backend/migrations/000007_photo_tags.up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS tags (
+ id text PRIMARY KEY,
+ user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS tags_user_name_idx ON tags (user_id, lower(name));
+
+CREATE TABLE IF NOT EXISTS photo_tags (
+ photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
+ tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (photo_id, tag_id)
+);
+
+CREATE INDEX IF NOT EXISTS photo_tags_tag_idx ON photo_tags (tag_id);
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 1244686..5323c6a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte';
-import type { Photo, Preferences } from './types';
+import type { Photo, Preferences, Tag } from './types';
export class ApiError extends Error {
status: number;
@@ -120,6 +120,19 @@ export const photosApi = {
body: { ids, album_id: albumId }
}),
trash: () => api<{ photos: Photo[] }>('/trash'),
- restore: (id: string) => api<{ photo: Photo }>(`/photos/${id}/restore`, { method: 'POST' }),
- permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' })
+ trashCount: () => api<{ count: number }>('/trash/count'),
+ restore: (id: string, albumId?: string) =>
+ api<{ photo: Photo }>(`/photos/${id}/restore`, {
+ method: 'POST',
+ body: albumId ? { album_id: albumId } : {}
+ }),
+ permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
+ addTag: (id: string, name: string) =>
+ api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
+ removeTag: (id: string, tagId: string) =>
+ api<{ photo: Photo }>(`/photos/${id}/tags/${tagId}`, { method: 'DELETE' })
+};
+
+export const tagsApi = {
+ list: (q = '') => api<{ tags: Tag[] }>(`/tags${q ? `?q=${encodeURIComponent(q)}` : ''}`)
};
diff --git a/frontend/src/lib/components/AlbumActionsDialogs.svelte b/frontend/src/lib/components/AlbumActionsDialogs.svelte
new file mode 100644
index 0000000..dabf0ac
--- /dev/null
+++ b/frontend/src/lib/components/AlbumActionsDialogs.svelte
@@ -0,0 +1,119 @@
+
+
+{#if albumActions.renaming}
+
{
+ if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
+ }}
+ onkeydown={(e) => {
+ if (e.key === 'Escape' && !busy) albumActions.renaming = null;
+ }}
+ >
+
+
+{/if}
+
+{#if albumActions.deleting}
+ (albumActions.deleting = null)}
+ />
+{/if}
diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte
index 5d549c9..71e2e16 100644
--- a/frontend/src/lib/components/AlbumPanel.svelte
+++ b/frontend/src/lib/components/AlbumPanel.svelte
@@ -4,16 +4,13 @@
import { api, photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import CoverPicker from './CoverPicker.svelte';
- import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
- import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
+ import PhotoPanel from './PhotoPanel.svelte';
import { albums } from '$lib/stores/albums.svelte';
- import { auth } from '$lib/stores/auth.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
- import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
- import type { Album, Photo, Preferences } from '$lib/types';
+ import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
@@ -24,40 +21,13 @@
let picking = $state(false);
let photo = $state(null);
- let editingName = $state(false);
- let nameDraft = $state('');
- let savingName = $state(false);
- let nameError = $state('');
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
const canEdit = $derived(role === 'owner' || role === 'admin');
- const canRename = $derived(
- !!panelPhoto && (canEdit || panelPhoto.uploader_id === auth.user?.id)
- );
- const exif = $derived((panelPhoto?.exif ?? {}) as Record);
-
- const prefs = $derived(auth.preferences);
- const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
- const visiblePrefs = $derived(prefs.photo_info?.visible);
- const effectiveVisible = $derived(
- new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
- );
- const allExifRows = $derived(buildExifRows(exif, labelOverrides));
- const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
- const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
- const attributeKeys = $derived(
- [...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
- (EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
- )
- );
-
- let settingsOpen = $state(false);
- let showAll = $state(false);
- let savingPrefs = $state(false);
let pendingDeleteSelected = $state(false);
let deletingSelected = $state(false);
@@ -79,21 +49,13 @@
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
}
- async function savePreferences(next: Preferences) {
- savingPrefs = true;
- try {
- const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
- method: 'PATCH',
- body: next
- });
- auth.setPreferences(res.preferences ?? {});
- showAll = false;
- settingsOpen = false;
- } catch (e) {
- error = (e as Error).message;
- } finally {
- savingPrefs = false;
- }
+ function onRenamed(updated: Photo) {
+ photo = updated;
+ if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
+ }
+
+ function onDeleted() {
+ selection.clear();
}
$effect(() => {
@@ -115,9 +77,6 @@
$effect(() => {
const id = photoId;
- editingName = false;
- nameError = '';
- showAll = false;
if (!id) {
photo = null;
return;
@@ -127,58 +86,6 @@
.catch((e) => (error = (e as Error).message));
});
- $effect(() => {
- // сброс временного состояния панели при выборе другого фото в сетке
- selection.ids.join(',');
- editingName = false;
- nameError = '';
- showAll = false;
- });
-
- function startEdit() {
- if (!panelPhoto) return;
- nameDraft = panelPhoto.original_filename;
- nameError = '';
- editingName = true;
- }
-
- async function saveName(ev: Event) {
- ev.preventDefault();
- if (!panelPhoto || !nameDraft.trim()) return;
- savingName = true;
- nameError = '';
- try {
- const data = await api<{ photo: Photo }>(`/photos/${panelPhoto.id}`, {
- method: 'PATCH',
- body: { original_filename: nameDraft.trim() }
- });
- photo = data.photo;
- if (selectedItems.some((p) => p.id === data.photo.id)) selection.replace(data.photo);
- editingName = false;
- } catch (e) {
- nameError = (e as Error).message;
- } finally {
- savingName = false;
- }
- }
-
- function autofocus(node: HTMLInputElement) {
- node.focus();
- node.select();
- }
-
- function formatSize(bytes: number): string {
- if (!bytes) return '—';
- const units = ['Б', 'КБ', 'МБ', 'ГБ'];
- let v = bytes;
- let i = 0;
- while (v >= 1024 && i < units.length - 1) {
- v /= 1024;
- i++;
- }
- return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
- }
-
async function resetCover() {
if (!album) return;
try {
@@ -190,7 +97,6 @@
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
- const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
{#snippet selectionActions()}
@@ -230,130 +136,14 @@
{/snippet}
{#if panelPhoto}
- {@const p = panelPhoto}
- {#if !photoId}{@render selectionActions()}{/if}
-
-
- {#if editingName}
-
- {#if nameError}
{nameError}
{/if}
- {:else if canRename}
-
- {:else}
-
{p.original_filename}
- {/if}
-
-
-
- {#snippet actions()}
-
- {/snippet}
-
- Автор{p.uploader.name}
- Файл{formatSize(p.size)}
- Тип{p.mime_type}
-
- Разрешение
- {p.width}×{p.height}
-
- {#each showAll ? allExifRows : visibleExifRows as row (row.key)}
-
- {row.label}
- {row.value}
-
- {/each}
- {#if hiddenCount > 0 && !showAll}
-
- {/if}
- {#if p.taken_at}
-
- Снято
- {new Date(p.taken_at).toLocaleString()}
-
- {/if}
- {#if p.latitude != null && p.longitude != null}
-
- Координаты
- {p.latitude.toFixed(5)}, {p.longitude.toFixed(5)}
-
- {/if}
-
- Загружено
- {new Date(p.created_at).toLocaleString()}
-
-
-
-
-
-
-
- {#if settingsOpen}
- savePreferences({})}
- onClose={() => (settingsOpen = false)}
- />
- {/if}
+
{:else if !photoId && selectedItems.length > 1}
{@render selectionActions()}
{:else if album && !photoId}
diff --git a/frontend/src/lib/components/AlbumRail.svelte b/frontend/src/lib/components/AlbumRail.svelte
index 59f839e..3e0dcd1 100644
--- a/frontend/src/lib/components/AlbumRail.svelte
+++ b/frontend/src/lib/components/AlbumRail.svelte
@@ -6,6 +6,10 @@
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
+ import { albumActions } from '$lib/stores/album-actions.svelte';
+ import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
+ import { trash } from '$lib/stores/trash.svelte';
+ import type { Album } from '$lib/types';
const activeId = $derived($page.params.id ?? '');
const onTrash = $derived($page.url.pathname === '/trash');
@@ -36,9 +40,24 @@
}
}
+ function onContextMenu(ev: MouseEvent, album: Album) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const items: ContextMenuItem[] = [
+ { label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
+ { label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
+ ];
+ contextMenu.show(ev.clientX, ev.clientY, items);
+ }
+
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
+
+ $effect(() => {
+ $page.url.pathname;
+ if (auth.isAuthenticated) trash.load();
+ });
@@ -79,6 +98,7 @@
ondragover={(e) => onDragOver(e, album.id)}
ondragleave={() => onDragLeave(album.id)}
ondrop={(e) => onDrop(e, album.id)}
+ oncontextmenu={(e) => onContextMenu(e, album)}
>
{album.title}
+ {album.photos_count ?? 0}
{/each}
@@ -116,6 +137,7 @@
Корзина
+ {trash.count}
{/if}
diff --git a/frontend/src/lib/components/ContextMenu.svelte b/frontend/src/lib/components/ContextMenu.svelte
new file mode 100644
index 0000000..7faec7f
--- /dev/null
+++ b/frontend/src/lib/components/ContextMenu.svelte
@@ -0,0 +1,63 @@
+
+
+ contextMenu.close()}
+ onscroll={() => contextMenu.close()}
+ onclick={() => contextMenu.close()}
+ oncontextmenu={() => contextMenu.close()}
+/>
+
+{#if contextMenu.open}
+ e.preventDefault()}
+ >
+ {#each contextMenu.items as item (item.label)}
+
+ {/each}
+
+{/if}
diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte
index ec254e6..09ad607 100644
--- a/frontend/src/lib/components/PhotoGrid.svelte
+++ b/frontend/src/lib/components/PhotoGrid.svelte
@@ -9,9 +9,11 @@
onSelect?: (index: number, event: MouseEvent) => void;
onOpen?: (index: number) => void;
onDragStart?: (index: number, event: DragEvent) => void;
+ onContextMenu?: (index: number, event: MouseEvent) => void;
}
- let { photos, selectedIds = [], onSelect, onOpen, onDragStart }: Props = $props();
+ let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
+ $props();
const isSelected = (id: string) => selectedIds.includes(id);
@@ -37,6 +39,7 @@
onclick={(e) => onSelect?.(i, e)}
ondblclick={() => onOpen?.(i)}
ondragstart={(e) => onDragStart?.(i, e)}
+ oncontextmenu={(e) => onContextMenu?.(i, e)}
>
{#if photo.thumb_url}
+ import { api, photosApi, tagsApi } from '$lib/api';
+ import Icon from './Icon.svelte';
+ import Comments from './Comments.svelte';
+ import ConfirmDialog from './ConfirmDialog.svelte';
+ import PanelSection from './PanelSection.svelte';
+ import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
+ import { auth } from '$lib/stores/auth.svelte';
+ import { transfer } from '$lib/stores/transfer.svelte';
+ import { viewerActions } from '$lib/stores/viewer-actions.svelte';
+ import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
+ import type { Photo, Preferences, Tag } from '$lib/types';
+
+ interface Props {
+ photo: Photo;
+ role: string;
+ viewer?: boolean;
+ onOpen?: () => void;
+ onRenamed?: (photo: Photo) => void;
+ onDeleted?: () => void;
+ }
+
+ let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
+
+ const canEdit = $derived(role === 'owner' || role === 'admin');
+ const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
+ const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
+
+ let editingName = $state(false);
+ let nameDraft = $state('');
+ let savingName = $state(false);
+ let nameError = $state('');
+
+ let pendingDelete = $state(false);
+ let deleting = $state(false);
+
+ let settingsOpen = $state(false);
+ let showAll = $state(false);
+ let savingPrefs = $state(false);
+
+ const prefs = $derived(auth.preferences);
+ const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
+ const visiblePrefs = $derived(prefs.photo_info?.visible);
+ const effectiveVisible = $derived(
+ new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
+ );
+ const exif = $derived((photo.exif ?? {}) as Record);
+ const allExifRows = $derived(buildExifRows(exif, labelOverrides));
+ const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
+ const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
+ const attributeKeys = $derived(
+ [...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
+ (EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
+ )
+ );
+
+ $effect(() => {
+ // сброс временного состояния при смене фото
+ photo.id;
+ editingName = false;
+ nameError = '';
+ showAll = false;
+ tagQuery = '';
+ tagOpen = false;
+ });
+
+ let tagQuery = $state('');
+ let tagOptions = $state([]);
+ let tagOpen = $state(false);
+ let tagBusy = $state(false);
+
+ const attachedTagIds = $derived(new Set((photo.tags ?? []).map((t) => t.id)));
+ const tagSuggestions = $derived(
+ tagOptions.filter((t) => !attachedTagIds.has(t.id))
+ );
+
+ $effect(() => {
+ const q = tagQuery;
+ if (!tagOpen) return;
+ const handle = setTimeout(async () => {
+ try {
+ tagOptions = (await tagsApi.list(q)).tags;
+ } catch {
+ tagOptions = [];
+ }
+ }, 180);
+ return () => clearTimeout(handle);
+ });
+
+ async function addTag(name: string) {
+ const n = name.trim();
+ if (!n || tagBusy) return;
+ tagBusy = true;
+ try {
+ const res = await photosApi.addTag(photo.id, n);
+ onRenamed?.(res.photo);
+ tagQuery = '';
+ tagOpen = false;
+ } catch {
+ /* ignore */
+ } finally {
+ tagBusy = false;
+ }
+ }
+
+ async function removeTag(tagId: string) {
+ try {
+ const res = await photosApi.removeTag(photo.id, tagId);
+ onRenamed?.(res.photo);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ function startEdit() {
+ nameDraft = photo.original_filename;
+ nameError = '';
+ editingName = true;
+ }
+
+ async function saveName(ev: Event) {
+ ev.preventDefault();
+ if (!nameDraft.trim()) return;
+ savingName = true;
+ nameError = '';
+ try {
+ const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
+ method: 'PATCH',
+ body: { original_filename: nameDraft.trim() }
+ });
+ onRenamed?.(data.photo);
+ editingName = false;
+ } catch (e) {
+ nameError = (e as Error).message;
+ } finally {
+ savingName = false;
+ }
+ }
+
+ function autofocus(node: HTMLInputElement) {
+ node.focus();
+ node.select();
+ }
+
+ function formatSize(bytes: number): string {
+ if (!bytes) return '—';
+ const units = ['Б', 'КБ', 'МБ', 'ГБ'];
+ let v = bytes;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
+ }
+
+ async function savePreferences(next: Preferences) {
+ savingPrefs = true;
+ try {
+ const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
+ method: 'PATCH',
+ body: next
+ });
+ auth.setPreferences(res.preferences ?? {});
+ showAll = false;
+ settingsOpen = false;
+ } catch {
+ /* ignore */
+ } finally {
+ savingPrefs = false;
+ }
+ }
+
+ function requestDelete() {
+ if (viewer) {
+ viewerActions.requestDelete(photo.id);
+ } else {
+ pendingDelete = true;
+ }
+ }
+
+ async function confirmDelete() {
+ deleting = true;
+ try {
+ await photosApi.deleteBatch([photo.id]);
+ pendingDelete = false;
+ onDeleted?.();
+ } catch {
+ /* ignore */
+ } finally {
+ deleting = false;
+ }
+ }
+
+ const navLink =
+ 'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
+ const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
+
+
+
+
+ {#if editingName}
+
+ {#if nameError}
{nameError}
{/if}
+ {:else if canRename}
+
+ {:else}
+
{photo.original_filename}
+ {/if}
+
+
+
+
+
+
+
+
+ {#if (photo.tags ?? []).length}
+
+ {#each photo.tags ?? [] as tag (tag.id)}
+
+ {tag.name}
+
+
+ {/each}
+
+ {/if}
+
+
(tagOpen = true)}
+ oninput={() => (tagOpen = true)}
+ onblur={() => setTimeout(() => (tagOpen = false), 150)}
+ onkeydown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ addTag(tagQuery);
+ } else if (e.key === 'Escape') {
+ tagOpen = false;
+ }
+ }}
+ placeholder="Добавить метку…"
+ maxlength="64"
+ aria-label="Метки фото"
+ class="border-border bg-bg text-text w-full rounded-md border px-2 py-1 text-sm"
+ />
+ {#if tagOpen && (tagSuggestions.length > 0 || tagQuery.trim())}
+
+ {#if tagQuery.trim()}
+
+ {/if}
+ {#each tagSuggestions as tag (tag.id)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+ {#snippet actions()}
+
+ {/snippet}
+
+ Автор{photo.uploader.name}
+ Файл{formatSize(photo.size)}
+ Тип{photo.mime_type}
+
+ Разрешение
+ {photo.width}×{photo.height}
+
+ {#each showAll ? allExifRows : visibleExifRows as row (row.key)}
+
+ {row.label}
+ {row.value}
+
+ {/each}
+ {#if hiddenCount > 0 && !showAll}
+
+ {/if}
+ {#if photo.taken_at}
+
+ Снято
+ {new Date(photo.taken_at).toLocaleString()}
+
+ {/if}
+ {#if photo.latitude != null && photo.longitude != null}
+
+ Координаты
+ {photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}
+
+ {/if}
+
+ Загружено
+ {new Date(photo.created_at).toLocaleString()}
+
+
+
+
+
+
+
+{#if settingsOpen}
+ savePreferences({})}
+ onClose={() => (settingsOpen = false)}
+ />
+{/if}
+
+{#if pendingDelete}
+ (pendingDelete = false)}
+ />
+{/if}
diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte
index 4861d07..0614195 100644
--- a/frontend/src/lib/components/Viewer.svelte
+++ b/frontend/src/lib/components/Viewer.svelte
@@ -1,19 +1,21 @@
-
+
{index + 1} / {photos.length}
-
+
- {#if fresh?.download_url}
-
-
-
- {/if}
- {#if canDelete}
-
- {/if}
@@ -365,6 +376,7 @@
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
+ oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts
index 2319b24..f8e6966 100644
--- a/frontend/src/lib/icons/registry.ts
+++ b/frontend/src/lib/icons/registry.ts
@@ -41,6 +41,8 @@ export const icons = {
delete_forever: { path: '
' },
content_copy: { path: '
' },
check: { path: '
' },
+ edit: { path: '
' },
+ label: { path: '
' },
menu: { path: '
' },
grid_view: { path: '
' },
wallpaper: { path: '
' },
diff --git a/frontend/src/lib/stores/album-actions.svelte.ts b/frontend/src/lib/stores/album-actions.svelte.ts
new file mode 100644
index 0000000..1fda6c1
--- /dev/null
+++ b/frontend/src/lib/stores/album-actions.svelte.ts
@@ -0,0 +1,21 @@
+import type { Album } from '$lib/types';
+
+class AlbumActionsStore {
+ renaming = $state
(null);
+ deleting = $state(null);
+
+ openRename(album: Album) {
+ this.renaming = album;
+ }
+
+ openDelete(album: Album) {
+ this.deleting = album;
+ }
+
+ close() {
+ this.renaming = null;
+ this.deleting = null;
+ }
+}
+
+export const albumActions = new AlbumActionsStore();
diff --git a/frontend/src/lib/stores/albums.svelte.ts b/frontend/src/lib/stores/albums.svelte.ts
index e9784b7..38e50bc 100644
--- a/frontend/src/lib/stores/albums.svelte.ts
+++ b/frontend/src/lib/stores/albums.svelte.ts
@@ -40,6 +40,20 @@ class AlbumsStore {
return data.album;
}
+ async rename(id: string, title: string): Promise {
+ const data = await api<{ album: Album }>(`/albums/${id}`, {
+ method: 'PATCH',
+ body: { title }
+ });
+ this.upsert(data.album);
+ return data.album;
+ }
+
+ async destroy(id: string): Promise {
+ await api(`/albums/${id}`, { method: 'DELETE' });
+ this.remove(id);
+ }
+
upsert(album: Album) {
const i = this.list.findIndex((a) => a.id === album.id);
if (i === -1) {
diff --git a/frontend/src/lib/stores/context-menu.svelte.ts b/frontend/src/lib/stores/context-menu.svelte.ts
new file mode 100644
index 0000000..eef451f
--- /dev/null
+++ b/frontend/src/lib/stores/context-menu.svelte.ts
@@ -0,0 +1,30 @@
+import type { IconName } from '$lib/icons/registry';
+
+export interface ContextMenuItem {
+ label: string;
+ icon: IconName;
+ danger?: boolean;
+ run: () => void;
+}
+
+class ContextMenuStore {
+ open = $state(false);
+ x = $state(0);
+ y = $state(0);
+ items = $state([]);
+
+ show(x: number, y: number, items: ContextMenuItem[]) {
+ if (items.length === 0) return;
+ this.x = x;
+ this.y = y;
+ this.items = items;
+ this.open = true;
+ }
+
+ close() {
+ this.open = false;
+ this.items = [];
+ }
+}
+
+export const contextMenu = new ContextMenuStore();
diff --git a/frontend/src/lib/stores/trash.svelte.ts b/frontend/src/lib/stores/trash.svelte.ts
new file mode 100644
index 0000000..76fc0b0
--- /dev/null
+++ b/frontend/src/lib/stores/trash.svelte.ts
@@ -0,0 +1,24 @@
+import { photosApi } from '$lib/api';
+import { auth } from './auth.svelte';
+
+class TrashStore {
+ count = $state(0);
+
+ async load() {
+ if (!auth.isAuthenticated) {
+ this.count = 0;
+ return;
+ }
+ try {
+ this.count = (await photosApi.trashCount()).count;
+ } catch {
+ /* ignore */
+ }
+ }
+
+ reset() {
+ this.count = 0;
+ }
+}
+
+export const trash = new TrashStore();
diff --git a/frontend/src/lib/stores/viewer-actions.svelte.ts b/frontend/src/lib/stores/viewer-actions.svelte.ts
new file mode 100644
index 0000000..7bfa1ac
--- /dev/null
+++ b/frontend/src/lib/stores/viewer-actions.svelte.ts
@@ -0,0 +1,13 @@
+class ViewerActions {
+ deleteRequested = $state(null);
+
+ requestDelete(id: string) {
+ this.deleteRequested = id;
+ }
+
+ clear() {
+ this.deleteRequested = null;
+ }
+}
+
+export const viewerActions = new ViewerActions();
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 1826313..99f9ff8 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -36,6 +36,11 @@ export interface AlbumMember {
user: User;
}
+export interface Tag {
+ id: string;
+ name: string;
+}
+
export interface Photo {
id: string;
album_id: string;
@@ -50,6 +55,7 @@ export interface Photo {
longitude?: number | null;
caption: string;
exif?: Record;
+ tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
created_at: string;
diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte
index d7d5f34..6f4a2ee 100644
--- a/frontend/src/routes/(app)/+layout.svelte
+++ b/frontend/src/routes/(app)/+layout.svelte
@@ -4,6 +4,7 @@
import AlbumRail from '$lib/components/AlbumRail.svelte';
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
+ import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
let { children } = $props();
@@ -21,3 +22,4 @@
+
diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte
index e0ec549..0a3ad10 100644
--- a/frontend/src/routes/(app)/+page.svelte
+++ b/frontend/src/routes/(app)/+page.svelte
@@ -4,10 +4,23 @@
import { albums } from '$lib/stores/albums.svelte';
import { density } from '$lib/stores/density.svelte';
import { auth } from '$lib/stores/auth.svelte';
+ import { albumActions } from '$lib/stores/album-actions.svelte';
+ import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
+ import type { Album } from '$lib/types';
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
+
+ function onAlbumContextMenu(ev: MouseEvent, album: Album) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const items: ContextMenuItem[] = [
+ { label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
+ { label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
+ ];
+ contextMenu.show(ev.clientX, ev.clientY, items);
+ }
{#if !auth.isAuthenticated}
@@ -44,7 +57,11 @@
>
{#each albums.list as album (album.id)}
-
+ onAlbumContextMenu(e, album)}
+ >
diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte
index b24e30c..4f75ee1 100644
--- a/frontend/src/routes/(app)/albums/[id]/+page.svelte
+++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte
@@ -1,14 +1,17 @@
{#if loading}
@@ -245,6 +326,7 @@
selectedIds={selection.albumId === albumId ? selection.ids : []}
onSelect={selectPhoto}
onDragStart={dragPhoto}
+ onContextMenu={openMenuForPhoto}
onOpen={(i) => {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
@@ -284,7 +366,19 @@
for (const p of created) {
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
}
+ loadAlbum().catch(() => {});
}}
/>
{/if}
+
+{#if pendingDelete}
+ (pendingDelete = false)}
+ />
+{/if}
diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte
index 4473b28..ea0dc79 100644
--- a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte
+++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte
@@ -4,25 +4,19 @@
import { api } from '$lib/api';
import Viewer from '$lib/components/Viewer.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
- import { auth } from '$lib/stores/auth.svelte';
+ import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
let photos = $state([]);
- let role = $state('');
let loading = $state(true);
let error = $state('');
let pendingDelete = $state(null);
let deleting = $state(false);
- const canEdit = $derived(role === 'owner' || role === 'admin');
const index = $derived(photos.findIndex((p) => p.id === photoId));
- const currentPhoto = $derived(index >= 0 ? photos[index] : null);
- const canDelete = $derived(
- canEdit || (!!currentPhoto && currentPhoto.uploader_id === auth.user?.id)
- );
$effect(() => {
const id = albumId;
@@ -32,9 +26,8 @@
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
api<{ album: Album; role: string }>(`/albums/${id}`)
])
- .then(([p, a]) => {
+ .then(([p]) => {
photos = p.photos;
- role = a.role;
})
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
@@ -49,9 +42,13 @@
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
}
- async function removePhoto(photo: Photo) {
- pendingDelete = photo;
- }
+ $effect(() => {
+ const id = viewerActions.deleteRequested;
+ if (!id) return;
+ const photo = photos.find((p) => p.id === id);
+ if (photo) pendingDelete = photo;
+ viewerActions.clear();
+ });
async function confirmDelete() {
const photo = pendingDelete;
@@ -92,14 +89,7 @@
{:else}
diff --git a/frontend/src/routes/(app)/trash/+page.svelte b/frontend/src/routes/(app)/trash/+page.svelte
index 62f5191..760c87a 100644
--- a/frontend/src/routes/(app)/trash/+page.svelte
+++ b/frontend/src/routes/(app)/trash/+page.svelte
@@ -1,7 +1,9 @@