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} + +{#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} + +{/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 @@