feat: context menus, unified photo panel, tags, album trash and counters
- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count - frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs - openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,6 +350,31 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
|
||||
}
|
||||
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
|
||||
@@ -354,17 +382,20 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
|
||||
}
|
||||
if p.DeletedAt == nil {
|
||||
return p, nil
|
||||
}
|
||||
if p.DeletedAt != 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS photo_tags;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
@@ -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);
|
||||
+16
-3
@@ -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)}` : ''}`)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Icon from './Icon.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { albumActions } from '$lib/stores/album-actions.svelte';
|
||||
|
||||
let title = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const a = albumActions.renaming;
|
||||
if (a) {
|
||||
title = a.title;
|
||||
error = '';
|
||||
}
|
||||
});
|
||||
|
||||
function autofocus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
node.select();
|
||||
}
|
||||
|
||||
async function saveRename(ev: Event) {
|
||||
ev.preventDefault();
|
||||
const a = albumActions.renaming;
|
||||
if (!a || !title.trim() || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await albums.rename(a.id, title.trim());
|
||||
albumActions.renaming = null;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const a = albumActions.deleting;
|
||||
if (!a) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await albums.destroy(a.id);
|
||||
albumActions.deleting = null;
|
||||
if ($page.params.id === a.id) await goto('/');
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if albumActions.renaming}
|
||||
<div
|
||||
use:portal
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Переименовать альбом"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && !busy) albumActions.renaming = null;
|
||||
}}
|
||||
>
|
||||
<form
|
||||
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
|
||||
onsubmit={saveRename}
|
||||
>
|
||||
<h2 class="text-text text-lg font-semibold">Переименовать альбом</h2>
|
||||
<label class="block">
|
||||
<span class="text-text-muted mb-1 block text-sm">Название</span>
|
||||
<input
|
||||
bind:value={title}
|
||||
use:autofocus
|
||||
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||
onclick={() => (albumActions.renaming = null)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !title.trim()}
|
||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<Icon name="check" class="size-4" />
|
||||
{busy ? 'Сохранение…' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if albumActions.deleting}
|
||||
<ConfirmDialog
|
||||
title="Удалить альбом?"
|
||||
message="Альбом «{albumActions.deleting.title}» будет удалён."
|
||||
confirmLabel="Удалить"
|
||||
busy={busy}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => (albumActions.deleting = null)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -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<Photo | null>(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<string, unknown>);
|
||||
|
||||
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';
|
||||
</script>
|
||||
|
||||
{#snippet selectionActions()}
|
||||
@@ -230,130 +136,14 @@
|
||||
{/snippet}
|
||||
|
||||
{#if panelPhoto}
|
||||
{@const p = panelPhoto}
|
||||
{#if !photoId}{@render selectionActions()}{/if}
|
||||
<div>
|
||||
<div class="p-4">
|
||||
{#if editingName}
|
||||
<form class="relative" onsubmit={saveName}>
|
||||
<input
|
||||
bind:value={nameDraft}
|
||||
use:autofocus
|
||||
aria-label="Имя файла"
|
||||
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
|
||||
<PhotoPanel
|
||||
photo={panelPhoto}
|
||||
{role}
|
||||
viewer={!!photoId}
|
||||
onOpen={openSelected}
|
||||
onRenamed={onRenamed}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
|
||||
<button
|
||||
type="submit"
|
||||
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
|
||||
aria-label="Сохранить"
|
||||
title="Сохранить"
|
||||
disabled={savingName || !nameDraft.trim()}
|
||||
>
|
||||
<Icon name="check" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||
aria-label="Отмена"
|
||||
title="Отмена"
|
||||
onclick={() => (editingName = false)}
|
||||
>
|
||||
<Icon name="close" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
|
||||
{:else if canRename}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
|
||||
title="Переименовать"
|
||||
onclick={startEdit}
|
||||
>
|
||||
{p.original_filename}
|
||||
</button>
|
||||
{:else}
|
||||
<h2 class="text-text truncate text-sm font-semibold">{p.original_filename}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PanelSection id="photo-info" title="Информация">
|
||||
{#snippet actions()}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Настройки атрибутов"
|
||||
title="Настройки атрибутов"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
settingsOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
<dl class="space-y-1">
|
||||
<div class={metaRow}><span>Автор</span><span class="text-text">{p.uploader.name}</span></div>
|
||||
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(p.size)}</span></div>
|
||||
<div class={metaRow}><span>Тип</span><span class="text-text">{p.mime_type}</span></div>
|
||||
<div class={metaRow}>
|
||||
<span>Разрешение</span>
|
||||
<span class="text-text">{p.width}×{p.height}</span>
|
||||
</div>
|
||||
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
||||
<div class={metaRow}>
|
||||
<span>{row.label}</span>
|
||||
<span class="text-text">{row.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if hiddenCount > 0 && !showAll}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
|
||||
aria-label="Показать все атрибуты"
|
||||
title="Показать все атрибуты"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
{/if}
|
||||
{#if p.taken_at}
|
||||
<div class={metaRow}>
|
||||
<span>Снято</span>
|
||||
<span class="text-text">{new Date(p.taken_at).toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if p.latitude != null && p.longitude != null}
|
||||
<div class={metaRow}>
|
||||
<span>Координаты</span>
|
||||
<span class="text-text">{p.latitude.toFixed(5)}, {p.longitude.toFixed(5)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={metaRow}>
|
||||
<span>Загружено</span>
|
||||
<span class="text-text">{new Date(p.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</dl>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection id="photo-comments" title="Комментарии">
|
||||
<Comments photoId={p.id} />
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
{#if settingsOpen}
|
||||
<PhotoInfoSettingsDialog
|
||||
attributes={attributeKeys}
|
||||
defaults={EXIF_LABELS}
|
||||
visible={[...effectiveVisible]}
|
||||
labels={prefs.photo_info?.labels ?? {}}
|
||||
busy={savingPrefs}
|
||||
onSave={savePreferences}
|
||||
onReset={() => savePreferences({})}
|
||||
onClose={() => (settingsOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if !photoId && selectedItems.length > 1}
|
||||
{@render selectionActions()}
|
||||
{:else if album && !photoId}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
@@ -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)}
|
||||
>
|
||||
<span
|
||||
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||
@@ -95,6 +115,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{album.title}</span>
|
||||
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -116,6 +137,7 @@
|
||||
<Icon name="delete" class="size-4 text-text-subtle" />
|
||||
</span>
|
||||
<span class="truncate">Корзина</span>
|
||||
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { contextMenu } from '$lib/stores/context-menu.svelte';
|
||||
|
||||
const width = 200;
|
||||
const itemH = 34;
|
||||
|
||||
const style = $derived(
|
||||
(() => {
|
||||
const pad = 8;
|
||||
let x = contextMenu.x;
|
||||
let y = contextMenu.y;
|
||||
if (typeof window !== 'undefined') {
|
||||
x = Math.min(x, window.innerWidth - width - pad);
|
||||
y = Math.min(y, window.innerHeight - contextMenu.items.length * itemH - pad);
|
||||
}
|
||||
return `left: ${Math.max(pad, x)}px; top: ${Math.max(pad, y)}px; min-width: ${width}px;`;
|
||||
})()
|
||||
);
|
||||
|
||||
function run(item: (typeof contextMenu.items)[number]) {
|
||||
contextMenu.close();
|
||||
item.run();
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') contextMenu.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={onKey}
|
||||
onresize={() => contextMenu.close()}
|
||||
onscroll={() => contextMenu.close()}
|
||||
onclick={() => contextMenu.close()}
|
||||
oncontextmenu={() => contextMenu.close()}
|
||||
/>
|
||||
|
||||
{#if contextMenu.open}
|
||||
<div
|
||||
use:portal
|
||||
class="border-border bg-bg-elevated fixed z-[70] overflow-hidden rounded-lg border py-1 shadow-lg"
|
||||
style={style}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{#each contextMenu.items as item (item.label)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm {item.danger
|
||||
? 'text-danger hover:bg-bg-muted'
|
||||
: 'text-text hover:bg-bg-muted'}"
|
||||
onclick={() => run(item)}
|
||||
>
|
||||
<Icon name={item.icon} class="size-4" />
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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);
|
||||
</script>
|
||||
@@ -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}
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
<script lang="ts">
|
||||
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<string, unknown>);
|
||||
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<Tag[]>([]);
|
||||
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';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="p-4">
|
||||
{#if editingName}
|
||||
<form class="relative" onsubmit={saveName}>
|
||||
<input
|
||||
bind:value={nameDraft}
|
||||
use:autofocus
|
||||
aria-label="Имя файла"
|
||||
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
|
||||
/>
|
||||
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
|
||||
<button
|
||||
type="submit"
|
||||
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
|
||||
aria-label="Сохранить"
|
||||
title="Сохранить"
|
||||
disabled={savingName || !nameDraft.trim()}
|
||||
>
|
||||
<Icon name="check" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||
aria-label="Отмена"
|
||||
title="Отмена"
|
||||
onclick={() => (editingName = false)}
|
||||
>
|
||||
<Icon name="close" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
|
||||
{:else if canRename}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
|
||||
title="Переименовать"
|
||||
onclick={startEdit}
|
||||
>
|
||||
{photo.original_filename}
|
||||
</button>
|
||||
{:else}
|
||||
<h2 class="text-text truncate text-sm font-semibold">{photo.original_filename}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PanelSection id="photo-actions" title="Действия">
|
||||
<nav class="space-y-0.5">
|
||||
{#if !viewer}
|
||||
<button type="button" class={navLink} onclick={() => onOpen?.()}>
|
||||
<Icon name="image" class="size-4" />
|
||||
Открыть (просмотр)
|
||||
</button>
|
||||
{/if}
|
||||
{#if photo.download_url}
|
||||
<a class={navLink} href={photo.download_url} target="_blank" rel="noreferrer" download>
|
||||
<Icon name="download" class="size-4" />
|
||||
Скачать
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class={navLink}
|
||||
onclick={() => transfer.start([photo.id], null, 'move')}
|
||||
>
|
||||
<Icon name="arrow_back" class="size-4" />
|
||||
Переместить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={navLink}
|
||||
onclick={() => transfer.start([photo.id], null, 'copy')}
|
||||
>
|
||||
<Icon name="content_copy" class="size-4" />
|
||||
Копировать
|
||||
</button>
|
||||
{#if canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
||||
onclick={requestDelete}
|
||||
>
|
||||
<Icon name="delete" class="size-4" />
|
||||
Удалить
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection id="photo-tags" title="Метки">
|
||||
<div class="space-y-2">
|
||||
{#if (photo.tags ?? []).length}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each photo.tags ?? [] as tag (tag.id)}
|
||||
<span
|
||||
class="border-border bg-bg-muted text-text inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-danger inline-flex"
|
||||
aria-label="Убрать метку {tag.name}"
|
||||
title="Убрать"
|
||||
onclick={() => removeTag(tag.id)}
|
||||
>
|
||||
<Icon name="close" class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<input
|
||||
bind:value={tagQuery}
|
||||
onfocus={() => (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())}
|
||||
<div
|
||||
class="border-border bg-bg-elevated absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border py-1 shadow-lg"
|
||||
>
|
||||
{#if tagQuery.trim()}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
addTag(tagQuery);
|
||||
}}
|
||||
>
|
||||
<Icon name="add" class="size-4" />
|
||||
Создать «{tagQuery.trim()}»
|
||||
</button>
|
||||
{/if}
|
||||
{#each tagSuggestions as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
addTag(tag.name);
|
||||
}}
|
||||
>
|
||||
<Icon name="label" class="size-4 text-text-subtle" />
|
||||
{tag.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection id="photo-info" title="Информация">
|
||||
{#snippet actions()}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Настройки атрибутов"
|
||||
title="Настройки атрибутов"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
settingsOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
<dl class="space-y-1">
|
||||
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
|
||||
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
|
||||
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
|
||||
<div class={metaRow}>
|
||||
<span>Разрешение</span>
|
||||
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||
</div>
|
||||
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
||||
<div class={metaRow}>
|
||||
<span>{row.label}</span>
|
||||
<span class="text-text">{row.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if hiddenCount > 0 && !showAll}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
|
||||
aria-label="Показать все атрибуты"
|
||||
title="Показать все атрибуты"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
{/if}
|
||||
{#if photo.taken_at}
|
||||
<div class={metaRow}>
|
||||
<span>Снято</span>
|
||||
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if photo.latitude != null && photo.longitude != null}
|
||||
<div class={metaRow}>
|
||||
<span>Координаты</span>
|
||||
<span class="text-text">{photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={metaRow}>
|
||||
<span>Загружено</span>
|
||||
<span class="text-text">{new Date(photo.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</dl>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection id="photo-comments" title="Комментарии">
|
||||
<Comments photoId={photo.id} />
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
{#if settingsOpen}
|
||||
<PhotoInfoSettingsDialog
|
||||
attributes={attributeKeys}
|
||||
defaults={EXIF_LABELS}
|
||||
visible={[...effectiveVisible]}
|
||||
labels={prefs.photo_info?.labels ?? {}}
|
||||
busy={savingPrefs}
|
||||
onSave={savePreferences}
|
||||
onReset={() => savePreferences({})}
|
||||
onClose={() => (settingsOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingDelete}
|
||||
<ConfirmDialog
|
||||
title="Удалить фото?"
|
||||
message={photo.original_filename}
|
||||
confirmLabel="Удалить"
|
||||
busy={deleting}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => (pendingDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,19 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { page } from '$app/stores';
|
||||
import Icon from './Icon.svelte';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import { transfer } from '$lib/stores/transfer.svelte';
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
index: number;
|
||||
canDelete?: boolean;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onDelete?: (photo: Photo) => void;
|
||||
}
|
||||
|
||||
let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props();
|
||||
let { photos, index, onClose, onIndexChange }: Props = $props();
|
||||
|
||||
let fresh = $state<Photo | null>(null);
|
||||
let touchX = 0;
|
||||
@@ -98,9 +100,7 @@
|
||||
});
|
||||
|
||||
const topClass = $derived(
|
||||
isFullscreen
|
||||
? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 border-b px-2 py-0.5 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}`
|
||||
: 'border-border bg-chrome flex shrink-0 items-center justify-between gap-1 border-b px-2 py-0.5'
|
||||
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
|
||||
);
|
||||
const bottomClass = $derived(
|
||||
isFullscreen
|
||||
@@ -288,22 +288,56 @@
|
||||
else if (dx < -50) next();
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = current;
|
||||
if (!p) return;
|
||||
const items: ContextMenuItem[] = [
|
||||
...(p.download_url
|
||||
? [
|
||||
{
|
||||
label: 'Скачать',
|
||||
icon: 'download' as const,
|
||||
run: () => window.open(p.download_url, '_blank')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Переместить',
|
||||
icon: 'arrow_back',
|
||||
run: () => transfer.start([p.id], null, 'move')
|
||||
},
|
||||
{
|
||||
label: 'Копировать',
|
||||
icon: 'content_copy',
|
||||
run: () => transfer.start([p.id], null, 'copy')
|
||||
},
|
||||
{
|
||||
label: 'Удалить',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
run: () => viewerActions.requestDelete(p.id)
|
||||
}
|
||||
];
|
||||
contextMenu.show(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
const btn =
|
||||
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
|
||||
const dangerBtn = 'text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1';
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
||||
|
||||
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
||||
<div bind:this={topEl} class={topClass}>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="pointer-events-auto flex items-center gap-1">
|
||||
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||
<Icon name="arrow_back" class="size-4" />
|
||||
</button>
|
||||
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="pointer-events-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={btn}
|
||||
@@ -316,29 +350,6 @@
|
||||
>
|
||||
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
|
||||
</button>
|
||||
{#if fresh?.download_url}
|
||||
<a
|
||||
href={fresh.download_url}
|
||||
class={btn}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Скачать"
|
||||
title="Скачать"
|
||||
>
|
||||
<Icon name="download" class="size-4" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class={dangerBtn}
|
||||
aria-label="Удалить фото"
|
||||
title="Удалить фото"
|
||||
onclick={() => current && onDelete?.(current)}
|
||||
>
|
||||
<Icon name="delete" class="size-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -365,6 +376,7 @@
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
oncontextmenu={onContextMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
|
||||
@@ -41,6 +41,8 @@ export const icons = {
|
||||
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
|
||||
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
|
||||
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' },
|
||||
edit: { path: '<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83l3.75 3.75z"/>' },
|
||||
label: { path: '<path fill="currentColor" d="M17.63 5.84C17.27 5.33 16.67 5 16 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h11c.67 0 1.27-.33 1.63-.84L22 12z"/>' },
|
||||
menu: { path: '<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>' },
|
||||
grid_view: { path: '<path fill="currentColor" d="M3 3v8h8V3zm10 0v8h8V3zM3 13v8h8v-8zm10 0v8h8v-8z"/>' },
|
||||
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Album } from '$lib/types';
|
||||
|
||||
class AlbumActionsStore {
|
||||
renaming = $state<Album | null>(null);
|
||||
deleting = $state<Album | null>(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();
|
||||
@@ -40,6 +40,20 @@ class AlbumsStore {
|
||||
return data.album;
|
||||
}
|
||||
|
||||
async rename(id: string, title: string): Promise<Album> {
|
||||
const data = await api<{ album: Album }>(`/albums/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { title }
|
||||
});
|
||||
this.upsert(data.album);
|
||||
return data.album;
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<ContextMenuItem[]>([]);
|
||||
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,13 @@
|
||||
class ViewerActions {
|
||||
deleteRequested = $state<string | null>(null);
|
||||
|
||||
requestDelete(id: string) {
|
||||
this.deleteRequested = id;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.deleteRequested = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const viewerActions = new ViewerActions();
|
||||
@@ -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<string, unknown>;
|
||||
tags?: Tag[];
|
||||
deleted_at?: string | null;
|
||||
album_title?: string;
|
||||
created_at: string;
|
||||
|
||||
@@ -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 @@
|
||||
</AppShell>
|
||||
|
||||
<TransferDialog />
|
||||
<AlbumActionsDialogs />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !auth.isAuthenticated}
|
||||
@@ -44,7 +57,11 @@
|
||||
>
|
||||
{#each albums.list as album (album.id)}
|
||||
<li>
|
||||
<a href="/albums/{album.id}" class="group block">
|
||||
<a
|
||||
href="/albums/{album.id}"
|
||||
class="group block"
|
||||
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
|
||||
>
|
||||
<div
|
||||
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
|
||||
>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { api, photosApi } from '$lib/api';
|
||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import Uploader from '$lib/components/Uploader.svelte';
|
||||
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import { transfer } from '$lib/stores/transfer.svelte';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
@@ -21,6 +24,8 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let dragging = $state(false);
|
||||
let pendingDelete = $state(false);
|
||||
let deletingMenu = $state(false);
|
||||
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
||||
|
||||
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
||||
@@ -86,10 +91,12 @@
|
||||
} else {
|
||||
photos = [photo, ...photos];
|
||||
}
|
||||
loadAlbum().catch(() => {});
|
||||
break;
|
||||
}
|
||||
case 'photo.deleted': {
|
||||
photos = photos.filter((p) => p.id !== data.id);
|
||||
loadAlbum().catch(() => {});
|
||||
break;
|
||||
}
|
||||
case 'photo.updated': {
|
||||
@@ -177,6 +184,80 @@
|
||||
uploader?.upload(ev.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
function openMenuForPhoto(i: number, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const photo = photos[i];
|
||||
if (!photo) return;
|
||||
const inSel = selection.albumId === albumId && selection.has(photo.id);
|
||||
const multi = inSel && selection.count > 1;
|
||||
if (!inSel) {
|
||||
selection.set(albumId, [photo]);
|
||||
selection.anchorId = photo.id;
|
||||
}
|
||||
const openFirst = () => {
|
||||
const first = selection.albumId === albumId ? selection.items[0] : photo;
|
||||
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
|
||||
};
|
||||
let items: ContextMenuItem[];
|
||||
if (multi) {
|
||||
const ids = selection.ids;
|
||||
items = [
|
||||
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
|
||||
{ label: 'Переместить', icon: 'arrow_back', run: () => transfer.start(ids, null, 'move') },
|
||||
{ label: 'Копировать', icon: 'content_copy', run: () => transfer.start(ids, null, 'copy') },
|
||||
{ label: 'Удалить', icon: 'delete', danger: true, run: () => (pendingDelete = true) }
|
||||
];
|
||||
} else {
|
||||
items = [
|
||||
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
|
||||
...(photo.download_url
|
||||
? [
|
||||
{
|
||||
label: 'Скачать',
|
||||
icon: 'download' as const,
|
||||
run: () => window.open(photo.download_url, '_blank')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Переместить',
|
||||
icon: 'arrow_back',
|
||||
run: () => transfer.start([photo.id], null, 'move')
|
||||
},
|
||||
{
|
||||
label: 'Копировать',
|
||||
icon: 'content_copy',
|
||||
run: () => transfer.start([photo.id], null, 'copy')
|
||||
},
|
||||
{
|
||||
label: 'Удалить',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
run: () => {
|
||||
selection.set(albumId, [photo]);
|
||||
selection.anchorId = photo.id;
|
||||
pendingDelete = true;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
contextMenu.show(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
async function confirmMenuDelete() {
|
||||
deletingMenu = true;
|
||||
try {
|
||||
await photosApi.deleteBatch(selection.ids);
|
||||
selection.clear();
|
||||
pendingDelete = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
deletingMenu = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingDelete}
|
||||
<ConfirmDialog
|
||||
title="Удалить выбранные фото?"
|
||||
message="{selection.count} фото будут перемещены в корзину."
|
||||
confirmLabel="Удалить"
|
||||
busy={deletingMenu}
|
||||
onConfirm={confirmMenuDelete}
|
||||
onClose={() => (pendingDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -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<Photo[]>([]);
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let pendingDelete = $state<Photo | null>(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}
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="min-h-0 flex-1">
|
||||
<Viewer
|
||||
{photos}
|
||||
{index}
|
||||
{canDelete}
|
||||
onClose={close}
|
||||
onIndexChange={change}
|
||||
onDelete={removePhoto}
|
||||
/>
|
||||
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { photosApi } from '$lib/api';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { photosApi, ApiError } from '$lib/api';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
let photos = $state<Photo[]>([]);
|
||||
@@ -10,6 +12,13 @@
|
||||
let pending = $state<Photo | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
let restoring = $state<Photo | null>(null);
|
||||
let targetId = $state('');
|
||||
let createNew = $state(false);
|
||||
let newTitle = $state('');
|
||||
let restoreBusy = $state(false);
|
||||
let restoreError = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
@@ -26,14 +35,49 @@
|
||||
load();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (restoring && !albums.loaded) albums.load();
|
||||
});
|
||||
|
||||
async function restore(p: Photo) {
|
||||
try {
|
||||
await photosApi.restore(p.id);
|
||||
photos = photos.filter((x) => x.id !== p.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === 'album_required') {
|
||||
restoring = p;
|
||||
targetId = '';
|
||||
createNew = false;
|
||||
newTitle = '';
|
||||
restoreError = '';
|
||||
} else {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
const photo = restoring;
|
||||
if (!photo) return;
|
||||
restoreBusy = true;
|
||||
restoreError = '';
|
||||
try {
|
||||
let id = targetId;
|
||||
if (createNew) {
|
||||
if (!newTitle.trim()) return;
|
||||
const album = await albums.create(newTitle.trim());
|
||||
id = album.id;
|
||||
}
|
||||
if (!id) return;
|
||||
await photosApi.restore(photo.id, id);
|
||||
photos = photos.filter((x) => x.id !== photo.id);
|
||||
restoring = null;
|
||||
} catch (e) {
|
||||
restoreError = (e as Error).message;
|
||||
} finally {
|
||||
restoreBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!pending) return;
|
||||
@@ -82,7 +126,9 @@
|
||||
<p class="text-text truncate text-xs" title={photo.original_filename}>
|
||||
{photo.original_filename}
|
||||
</p>
|
||||
<p class="text-text-subtle truncate text-xs">{photo.album_title ?? ''}</p>
|
||||
<p class="text-text-subtle truncate text-xs">
|
||||
{photo.album_title || 'Без альбома'}
|
||||
</p>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -119,3 +165,103 @@
|
||||
onClose={() => (pending = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if restoring}
|
||||
<div
|
||||
use:portal
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Восстановление фото"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget && !restoreBusy) restoring = null;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && !restoreBusy) restoring = null;
|
||||
}}
|
||||
>
|
||||
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
|
||||
<div class="border-border flex items-center justify-between border-b px-5 py-3">
|
||||
<h2 class="text-text text-base font-semibold">Восстановить фото</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
|
||||
aria-label="Закрыть"
|
||||
onclick={() => (restoring = null)}
|
||||
>
|
||||
<Icon name="close" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||
<p class="text-text-muted text-xs">
|
||||
Исходный альбом удалён. Выберите альбом или создайте новый.
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" class="accent-primary size-4" bind:checked={createNew} />
|
||||
Создать новый альбом
|
||||
</label>
|
||||
|
||||
{#if createNew}
|
||||
<input
|
||||
bind:value={newTitle}
|
||||
placeholder="Название альбома"
|
||||
maxlength="120"
|
||||
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2 text-sm"
|
||||
/>
|
||||
{:else if albums.list.length === 0}
|
||||
<p class="text-text-subtle text-sm">Нет альбомов — создайте новый.</p>
|
||||
{:else}
|
||||
<ul class="max-h-56 space-y-1 overflow-y-auto">
|
||||
{#each albums.list as album (album.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
|
||||
album.id
|
||||
? 'border-primary bg-bg-muted text-text'
|
||||
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
|
||||
aria-pressed={targetId === album.id}
|
||||
onclick={() => (targetId = album.id)}
|
||||
>
|
||||
<span
|
||||
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||
>
|
||||
{#if album.cover_thumb_url}
|
||||
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<Icon name="image" class="size-4 text-text-subtle" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{album.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if restoreError}<p class="text-danger text-sm">{restoreError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||
onclick={() => (restoring = null)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={restoreBusy || (createNew ? !newTitle.trim() : !targetId)}
|
||||
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
onclick={confirmRestore}
|
||||
>
|
||||
{restoreBusy ? 'Восстановление…' : 'Восстановить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
import IconSprite from '$lib/components/IconSprite.svelte';
|
||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { density } from '$lib/stores/density.svelte';
|
||||
@@ -23,3 +24,4 @@
|
||||
|
||||
<IconSprite />
|
||||
{@render children()}
|
||||
<ContextMenu />
|
||||
|
||||
@@ -205,9 +205,60 @@ owner/admin) и загрузка в цель (member+).
|
||||
### 24. Компактный верхний хром
|
||||
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
|
||||
минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок,
|
||||
иконки `size-4`, компактный переключатель темы. Функциональность (меню, панель, темы, полный
|
||||
иконки `size-4`, компактный переключатель темы. Верхняя панель просмотра — прозрачный оверлей
|
||||
(`absolute`, без фона и рамки, `pointer-events-none` с активными кнопками), поэтому не занимает
|
||||
место в раскладке и увеличивает область фото. Функциональность (меню, панель, темы, полный
|
||||
экран, навигация, скачивание, удаление) сохраняется.
|
||||
|
||||
### 25. Действия в режиме просмотра — в правой панели
|
||||
Скачивание и удаление убраны из верхней панели просмотра и перенесены в правую панель, в раздел
|
||||
«Действия», вместе с «Переместить» и «Копировать». Скачивание — ссылка на presigned оригинал;
|
||||
перенос/копирование — через `transfer`/`TransferDialog`; удаление инициируется стором
|
||||
`viewerActions`, а обрабатывает его страница просмотра (та же логика перехода к соседнему фото
|
||||
после удаления).
|
||||
|
||||
### 26. Единая панель фото
|
||||
Содержимое правой панели для одного фото (имя файла, «Действия», «Информация», «Комментарии»)
|
||||
вынесено в общий компонент `PhotoPanel`, который переиспользуется и в режиме просмотра
|
||||
(`viewer=true`), и при одиночном выборе фото в сетке — это одна и та же панель, а не копия.
|
||||
Отличие только в наборе действий: в просмотре нет «Открыть», а удаление идёт через стор
|
||||
`viewerActions` (обрабатывает страница просмотра с переходом к соседнему фото); при выборе в
|
||||
сетке удаление выполняет сам компонент и очищает выбор. Мультивыбор (>1) показывает только
|
||||
раздел «Действия».
|
||||
|
||||
### 27. Контекстное меню фото
|
||||
Правый клик по фото в сетке и по изображению в просмотре открывает контекстное меню с теми же
|
||||
пунктами, что и раздел «Действия». Меню — общий стор `contextMenu` (`x`, `y`, items) и глобальный
|
||||
компонент `ContextMenu` (портал), закрывается по клику вне, `Escape`, скроллу и ресайзу.
|
||||
Пункты формируются на местах: в сетке — «Открыть (просмотр)», «Скачать», «Переместить»,
|
||||
«Копировать», «Удалить» (для мультивыбора без «Скачать»); в просмотре — без «Открыть».
|
||||
|
||||
### 28. Удаление альбома и восстановление фото
|
||||
Удаление альбома не должно терять файлы: перед удалением альбома его фото мягко удаляются
|
||||
(`deleted_at`), а FK `photos.album_id` меняется на `ON DELETE SET NULL` (колонка nullable,
|
||||
миграция `000006`) — после удаления альбома фото остаются строками с `album_id = NULL` в
|
||||
корзине. Объекты S3 не удаляются (каскадный `DeletePrefix` убран: он ломал бы копии в других
|
||||
альбомах). `GET /trash` включает такие «без альбома» фото загрузчика. Восстановление с
|
||||
указанием целевого альбома (`POST /photos/:id/restore {album_id}`) переносит фото туда; без
|
||||
цели и без альбома возвращается ошибка `album_required`, и клиент предлагает выбрать альбом или
|
||||
создать новый.
|
||||
|
||||
### 29. Контекстное меню альбома и счётчики в рейле
|
||||
Правый клик по альбому (в рейле и на обзоре) открывает меню «Переименовать»/«Удалить» через
|
||||
общий стор `contextMenu`; диалоги переименования и подтверждения удаления — глобальный
|
||||
`AlbumActionsDialogs` (стор `albumActions`). В рейле у каждого альбома показано число фото
|
||||
(`album.photos_count`), у корзины — число удалённых фото (лёгкий `GET /trash/count`, стор
|
||||
`trash`).
|
||||
|
||||
### 30. Метки фото (область — пользователь)
|
||||
Метки принадлежат пользователю: таблицы `tags` (`user_id`, уникальность по `lower(name)`) и
|
||||
`photo_tags` (миграция `000007`). Фото отдаёт метки текущего пользователя (подзапрос
|
||||
`json_agg` в `photoColumns` по `$2`-viewer). Эндпоинты: `GET /tags?q=` (поиск по меткам
|
||||
пользователя), `POST /photos/:id/tags {name}` (создаёт метку при необходимости и привязывает,
|
||||
возвращает обновлённое фото), `DELETE /photos/:id/tags/:tag_id`. UI — раздел «Метки» в
|
||||
`PhotoPanel`: чипы с удалением и поле с автодополнением (поиск + «Создать «…»»). Права: метку
|
||||
можно ставить на фото альбома, доступного пользователю.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||
|
||||
@@ -35,10 +35,19 @@
|
||||
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
|
||||
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
|
||||
фото есть команда бэкфилла метаданных из оригиналов.
|
||||
- **Метки фото.** Пользователь присваивает фото свои метки: создаёт новые и выбирает из списка
|
||||
с поиском; метки показываются и снимаются в разделе «Метки» панели фото.
|
||||
- **Контекстные меню.** Правый клик по фото (в сетке и в просмотре) открывает меню с пунктами
|
||||
раздела «Действия»; правый клик по альбому (в рейле и на обзоре) — меню «Переименовать»/
|
||||
«Удалить».
|
||||
- **Удаление альбома.** Удаление альбома помещает его фото в корзину, не удаляя объекты
|
||||
хранилища; при восстановлении фото без альбома предлагается выбрать существующий альбом или
|
||||
создать новый.
|
||||
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
|
||||
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
|
||||
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
|
||||
удаление текущего фото с модальным подтверждением; возврат к обзору.
|
||||
действия просмотра (скачать, удалить, перенести, копировать) — в правой панели, в разделе
|
||||
«Действия»; удаление с модальным подтверждением; возврат к обзору.
|
||||
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
||||
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
||||
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
|
||||
@@ -55,7 +64,8 @@
|
||||
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
|
||||
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
|
||||
обзоре убрана пустая правая панель.
|
||||
- **Счётчики.** В ответе альбома — число фото и участников.
|
||||
- **Счётчики.** В ответе альбома — число фото и участников; в рейле у каждого альбома показано
|
||||
число фото, у корзины — число удалённых фото.
|
||||
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
|
||||
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
|
||||
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
|
||||
@@ -72,9 +82,10 @@
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `albums`: отображение обложки, счётчики фото/участников и явный выбор обложки.
|
||||
- `albums`: отображение обложки, счётчики фото/участников, явный выбор обложки и удаление в
|
||||
корзину.
|
||||
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
|
||||
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование и корзина.
|
||||
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование, корзина и метки.
|
||||
- `auth`: хранение пользовательских настроек отображения информации в профиле.
|
||||
|
||||
## Impact
|
||||
@@ -89,7 +100,11 @@
|
||||
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
|
||||
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
|
||||
`models.User.Preferences`, `internal/auth` (`Me` отдаёт настройки,
|
||||
`PATCH /auth/me/preferences` с валидацией).
|
||||
`PATCH /auth/me/preferences` с валидацией); миграции `000005_photo_trash` (мягкое удаление),
|
||||
`000006_photo_album_nullable` (FK `photos.album_id` → `ON DELETE SET NULL`, удаление альбома
|
||||
в корзину), `000007_photo_tags` (метки); `GET /trash`/`GET /trash/count`,
|
||||
`POST /photos/:id/restore` (c `album_id`), `DELETE /photos/:id/permanent`,
|
||||
`POST /photo-batch/{delete,move,copy}`, `GET /tags`, `POST/DELETE /photos/:id/tags`.
|
||||
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
|
||||
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
|
||||
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
|
||||
@@ -97,7 +112,9 @@
|
||||
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
|
||||
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
|
||||
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
|
||||
`photos_count`, `preferences`); новые иконки в реестре.
|
||||
`photos_count`, `preferences`, `tags`); общие компоненты `PhotoPanel`, `ContextMenu`,
|
||||
`TransferDialog`, `AlbumActionsDialogs`; маршрут `/trash`; сторы `selection`, `transfer`,
|
||||
`contextMenu`, `albumActions`, `trash`, `viewerActions`; новые иконки в реестре.
|
||||
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
|
||||
затрагивается — иконки по-прежнему наследуют цвет темы.
|
||||
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
|
||||
|
||||
@@ -87,3 +87,32 @@
|
||||
#### Scenario: Окончательное удаление при наличии ссылок
|
||||
- **WHEN** на объект хранилища ссылается копия в другом альбоме
|
||||
- **THEN** запись удаляется, а объект хранилища сохраняется
|
||||
|
||||
### Requirement: Удаление альбома и восстановление фото без альбома
|
||||
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
|
||||
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
|
||||
альбом или создать новый.
|
||||
|
||||
#### Scenario: Удаление альбома
|
||||
- **WHEN** владелец удаляет альбом
|
||||
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
|
||||
|
||||
#### Scenario: Восстановление без альбома
|
||||
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
|
||||
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
|
||||
|
||||
### Requirement: Метки фото
|
||||
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
|
||||
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
|
||||
|
||||
#### Scenario: Создание метки
|
||||
- **WHEN** пользователь вводит новую метку и подтверждает
|
||||
- **THEN** метка создаётся и привязывается к фото
|
||||
|
||||
#### Scenario: Выбор из списка с поиском
|
||||
- **WHEN** пользователь вводит часть названия метки
|
||||
- **THEN** показываются подходящие метки пользователя для выбора
|
||||
|
||||
#### Scenario: Снятие метки
|
||||
- **WHEN** пользователь убирает метку с фото
|
||||
- **THEN** метка отвязывается от фото
|
||||
|
||||
@@ -316,10 +316,69 @@ Ctrl/Cmd-клик — добавить или убрать, Shift-клик —
|
||||
- **WHEN** пользователь открывает обзор альбомов
|
||||
- **THEN** правая панель не отображается
|
||||
|
||||
### Requirement: Действия в режиме просмотра
|
||||
В режиме просмотра правая панель SHALL содержать раздел «Действия» со строками-пунктами
|
||||
(иконка + подпись) «Скачать», «Переместить», «Копировать» и «Удалить» (удаление — по правам).
|
||||
Верхняя панель просмотра SHALL не дублировать скачивание и удаление.
|
||||
|
||||
#### Scenario: Действия в просмотре
|
||||
- **WHEN** открыт режим просмотра фото
|
||||
- **THEN** раздел «Действия» содержит «Скачать», «Переместить», «Копировать» и «Удалить», а в верхней панели этих кнопок нет
|
||||
|
||||
#### Scenario: Удаление из панели
|
||||
- **WHEN** пользователь удаляет фото из раздела «Действия»
|
||||
- **THEN** открывается подтверждение, и после удаления просмотр переходит к соседнему фото
|
||||
|
||||
### Requirement: Контекстное меню фото
|
||||
Правый клик по фото в сетке и по изображению в режиме просмотра SHALL открывать контекстное
|
||||
меню с пунктами раздела «Действия» («Скачать», «Переместить», «Копировать», «Удалить»; в сетке
|
||||
также «Открыть (просмотр)»), выполняющими те же действия.
|
||||
|
||||
#### Scenario: Меню в сетке
|
||||
- **WHEN** пользователь кликает правой кнопкой по фото в сетке
|
||||
- **THEN** открывается меню с пунктами «Открыть (просмотр)», «Скачать», «Переместить», «Копировать», «Удалить»
|
||||
|
||||
#### Scenario: Меню в просмотре
|
||||
- **WHEN** пользователь кликает правой кнопкой по изображению в режиме просмотра
|
||||
- **THEN** открывается меню с пунктами «Скачать», «Переместить», «Копировать», «Удалить»
|
||||
|
||||
### Requirement: Контекстное меню альбома
|
||||
Правый клик по альбому в рейле и по карточке на обзоре SHALL открывать меню с пунктами
|
||||
«Переименовать» и «Удалить»; переименование SHALL открывать диалог, удаление — подтверждение.
|
||||
|
||||
#### Scenario: Меню альбома
|
||||
- **WHEN** пользователь кликает правой кнопкой по альбому
|
||||
- **THEN** открывается меню с «Переименовать» и «Удалить»
|
||||
|
||||
#### Scenario: Удаление альбома
|
||||
- **WHEN** пользователь подтверждает удаление альбома
|
||||
- **THEN** альбом удаляется, а его фото попадают в корзину
|
||||
|
||||
### Requirement: Счётчики в рейле
|
||||
Рейл SHALL показывать число фото у каждого альбома и число элементов в корзине.
|
||||
|
||||
#### Scenario: Счётчики
|
||||
- **WHEN** пользователь смотрит на рейл альбомов
|
||||
- **THEN** у каждого альбома показано число фото, а у корзины — число удалённых фото
|
||||
|
||||
### Requirement: Метки в панели фото
|
||||
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
|
||||
поиск по меткам пользователя и создание новых.
|
||||
|
||||
#### Scenario: Раздел «Метки»
|
||||
- **WHEN** открыт просмотр или выбран один фото в сетке
|
||||
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
|
||||
|
||||
### Requirement: Компактный верхний хром
|
||||
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
|
||||
(узкие отступы, иконочные органы управления), сохраняя все действия доступными.
|
||||
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
|
||||
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
|
||||
увеличивая область фото.
|
||||
|
||||
#### Scenario: Компактные верхние панели
|
||||
- **WHEN** открыт список альбомов или режим просмотра фото
|
||||
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
|
||||
|
||||
#### Scenario: Прозрачная панель просмотра
|
||||
- **WHEN** открыт режим просмотра фото
|
||||
- **THEN** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения
|
||||
|
||||
@@ -76,3 +76,10 @@
|
||||
- [x] 9.15 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||
- [x] 9.16 Раздел «Действия» в панели — пункты-надписи с иконками (не кнопки): «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; надпись «Выбрано:» убрана. Проверка: Playwright — «Открыть (просмотр)» открывает просмотр, раздел присутствует (4/4).
|
||||
- [x] 9.17 Компактный верхний хром: верхняя панель приложения и верхняя панель режима просмотра сведены к минимуму (узкие отступы, иконочные кнопки `size-4`, компактный переключатель темы). Проверка: высота `<header>` ~31px, верхней панели просмотра ~29px; `npm run check`.
|
||||
- [x] 9.18 Действия просмотра перенесены в правую панель: в разделе «Действия» — «Скачать», «Переместить», «Копировать», «Удалить»; из верхней панели просмотра скачивание и удаление убраны; удаление через стор `viewerActions` и страницу просмотра. Проверка: Playwright — верх без скачать/удалить, панель с действиями, удаление из панели переходит к соседнему фото (9/9).
|
||||
- [x] 9.19 Единая панель фото: содержимое для одного фото вынесено в общий компонент `PhotoPanel`, переиспользуемый в просмотре и при одиночном выборе в сетке (не копия); мультивыбор — только «Действия». Проверка: Playwright — просмотр 9/9, выбор 5/5, мультивыбор 9/9; `npm run check`.
|
||||
- [x] 9.20 Контекстное меню фото (правая кнопка) в сетке и просмотре с пунктами из «Действий»; общий стор `contextMenu` и глобальный `ContextMenu`. Проверка: Playwright — меню в сетке (5 пунктов), меню в просмотре (4 пункта), «Открыть» из меню и «Переместить» → диалог (12/12).
|
||||
- [x] 9.21 Контекстное меню альбома («Переименовать»/«Удалить») в рейле и на обзоре; диалоги в `AlbumActionsDialogs`. Проверка: Playwright — меню, переименование, удаление (4/4).
|
||||
- [x] 9.22 Удаление альбома помещает фото в корзину без потери файлов (FK `photos.album_id` → `ON DELETE SET NULL`, миграция `000006`); восстановление без альбома предлагает выбрать/создать альбом (`album_required` + `POST /photos/:id/restore {album_id}`). Проверка: Playwright — удалить альбом → фото в корзине, диалог восстановления, фото в новом альбоме (7/7).
|
||||
- [x] 9.23 Счётчики в рейле: число фото у альбома и число элементов корзины (`GET /trash/count`, стор `trash`); обновление счётчика альбома после загрузки/удаления. Проверка: Playwright — альбом 2, корзина 0→1 (3/3).
|
||||
- [x] 9.24 Метки фото (область — пользователь): таблицы `tags`/`photo_tags` (миграция `000007`), `GET /tags?q=`, `POST /photos/:id/tags`, `DELETE /photos/:id/tags/:tag_id`, метки в ответе фото; раздел «Метки» в панели с чипами, поиском и созданием. Проверка: API — создание/поиск/снятие; Playwright — создание, снятие, выбор из поиска (4/4).
|
||||
|
||||
Reference in New Issue
Block a user