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 {
|
if role != roles.Owner {
|
||||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
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
|
return err
|
||||||
}
|
}
|
||||||
if s.storage != nil {
|
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||||
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
|
return err
|
||||||
}
|
}
|
||||||
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -107,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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.GET("/photos/:id/download", photosHandler.Download)
|
||||||
authed.POST("/photos/:id/restore", photosHandler.Restore)
|
authed.POST("/photos/:id/restore", photosHandler.Restore)
|
||||||
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
|
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/delete", photosHandler.DeleteBatch)
|
||||||
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
|
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
|
||||||
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
|
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
|
||||||
authed.GET("/trash", photosHandler.Trash)
|
authed.GET("/trash", photosHandler.Trash)
|
||||||
|
authed.GET("/trash/count", photosHandler.TrashCount)
|
||||||
|
|
||||||
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
||||||
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ type Photo struct {
|
|||||||
Longitude *float64 `json:"longitude,omitempty"`
|
Longitude *float64 `json:"longitude,omitempty"`
|
||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
EXIF map[string]any `json:"exif,omitempty"`
|
EXIF map[string]any `json:"exif,omitempty"`
|
||||||
|
Tags []Tag `json:"tags,omitempty"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
AlbumTitle string `json:"album_title,omitempty"`
|
AlbumTitle string `json:"album_title,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -91,6 +92,11 @@ type Photo struct {
|
|||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
PhotoID string `json:"photo_id"`
|
PhotoID string `json:"photo_id"`
|
||||||
|
|||||||
@@ -143,7 +143,11 @@ func (h *Handler) PermanentDelete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Restore(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 {
|
if err != nil {
|
||||||
httpx.RespondError(c, err)
|
httpx.RespondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -160,6 +164,49 @@ func (h *Handler) Trash(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"photos": list})
|
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 {
|
type batchRequest struct {
|
||||||
IDs []string `json:"ids" binding:"required"`
|
IDs []string `json:"ids" binding:"required"`
|
||||||
AlbumID string `json:"album_id"`
|
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.
|
// 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)
|
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -347,6 +350,31 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
|
|||||||
}
|
}
|
||||||
return nil, err
|
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)
|
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -354,17 +382,20 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
|
|||||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||||
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
|
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
|
||||||
}
|
}
|
||||||
if p.DeletedAt == nil {
|
if p.DeletedAt != nil {
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
|
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
p.DeletedAt = nil
|
p.DeletedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.attachURLs(ctx, p); err != nil {
|
if err := s.attachURLs(ctx, p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if p.AlbumID != "" {
|
||||||
s.hub.Publish(p.AlbumID, "photo.created", p)
|
s.hub.Publish(p.AlbumID, "photo.created", p)
|
||||||
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,3 +523,66 @@ func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, err
|
|||||||
}
|
}
|
||||||
return out, nil
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shared-album/internal/auth"
|
||||||
"shared-album/internal/models"
|
"shared-album/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ type Store struct {
|
|||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
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, ''),
|
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
||||||
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
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,
|
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 likes l WHERE l.photo_id = p.id),
|
||||||
(SELECT count(*) FROM comments c WHERE c.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),
|
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) {
|
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
var exifText string
|
var exifText, tagsText string
|
||||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.PreviewKey,
|
&p.ThumbKey, &p.PreviewKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -47,9 +52,20 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
setExif(&p, exifText)
|
setExif(&p, exifText)
|
||||||
|
setTags(&p, tagsText)
|
||||||
return &p, nil
|
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) {
|
func setExif(p *models.Photo, raw string) {
|
||||||
if raw == "" || raw == "null" || raw == "{}" {
|
if raw == "" || raw == "null" || raw == "{}" {
|
||||||
return
|
return
|
||||||
@@ -118,16 +134,17 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
|||||||
out := []models.Photo{}
|
out := []models.Photo{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
var exifText string
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.PreviewKey,
|
&p.ThumbKey, &p.PreviewKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
setExif(&p, exifText)
|
setExif(&p, exifText)
|
||||||
|
setTags(&p, tagsText)
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -199,16 +216,20 @@ func (s *Store) HardDelete(ctx context.Context, id string) error {
|
|||||||
return nil
|
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) {
|
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
`SELECT `+photoColumns+`, a.title
|
`SELECT `+photoColumns+`, COALESCE(a.title, '')
|
||||||
FROM photos p
|
FROM photos p
|
||||||
JOIN users u ON u.id = p.uploader_id
|
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
|
WHERE p.deleted_at IS NOT NULL
|
||||||
AND (a.owner_id = $1 OR EXISTS(
|
AND (
|
||||||
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))
|
(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
|
ORDER BY p.deleted_at DESC
|
||||||
LIMIT 200`,
|
LIMIT 200`,
|
||||||
userID, userID)
|
userID, userID)
|
||||||
@@ -220,21 +241,127 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
|||||||
out := []models.Photo{}
|
out := []models.Photo{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
var exifText string
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.PreviewKey,
|
&p.ThumbKey, &p.PreviewKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
setExif(&p, exifText)
|
setExif(&p, exifText)
|
||||||
|
setTags(&p, tagsText)
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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 {
|
func nullIfEmpty(s string) *string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
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 { goto } from '$app/navigation';
|
||||||
import { auth, type Tokens, type User } from './stores/auth.svelte';
|
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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -120,6 +120,19 @@ export const photosApi = {
|
|||||||
body: { ids, album_id: albumId }
|
body: { ids, album_id: albumId }
|
||||||
}),
|
}),
|
||||||
trash: () => api<{ photos: Photo[] }>('/trash'),
|
trash: () => api<{ photos: Photo[] }>('/trash'),
|
||||||
restore: (id: string) => api<{ photo: Photo }>(`/photos/${id}/restore`, { method: 'POST' }),
|
trashCount: () => api<{ count: number }>('/trash/count'),
|
||||||
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' })
|
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 { api, photosApi } from '$lib/api';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import CoverPicker from './CoverPicker.svelte';
|
import CoverPicker from './CoverPicker.svelte';
|
||||||
import Comments from './Comments.svelte';
|
|
||||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
import PanelSection from './PanelSection.svelte';
|
import PanelSection from './PanelSection.svelte';
|
||||||
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
|
import PhotoPanel from './PhotoPanel.svelte';
|
||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
|
||||||
import { selection } from '$lib/stores/selection.svelte';
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
import { transfer } from '$lib/stores/transfer.svelte';
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
|
import type { Album, Photo } from '$lib/types';
|
||||||
import type { Album, Photo, Preferences } from '$lib/types';
|
|
||||||
|
|
||||||
const albumId = $derived($page.params.id ?? '');
|
const albumId = $derived($page.params.id ?? '');
|
||||||
const photoId = $derived($page.params.photoId ?? '');
|
const photoId = $derived($page.params.photoId ?? '');
|
||||||
@@ -24,40 +21,13 @@
|
|||||||
let picking = $state(false);
|
let picking = $state(false);
|
||||||
|
|
||||||
let photo = $state<Photo | null>(null);
|
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 selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
|
||||||
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
|
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
|
||||||
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
|
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
|
||||||
|
|
||||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
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 pendingDeleteSelected = $state(false);
|
||||||
let deletingSelected = $state(false);
|
let deletingSelected = $state(false);
|
||||||
|
|
||||||
@@ -79,21 +49,13 @@
|
|||||||
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
|
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePreferences(next: Preferences) {
|
function onRenamed(updated: Photo) {
|
||||||
savingPrefs = true;
|
photo = updated;
|
||||||
try {
|
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
||||||
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 onDeleted() {
|
||||||
|
selection.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -115,9 +77,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = photoId;
|
const id = photoId;
|
||||||
editingName = false;
|
|
||||||
nameError = '';
|
|
||||||
showAll = false;
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
photo = null;
|
photo = null;
|
||||||
return;
|
return;
|
||||||
@@ -127,58 +86,6 @@
|
|||||||
.catch((e) => (error = (e as Error).message));
|
.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() {
|
async function resetCover() {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +97,6 @@
|
|||||||
|
|
||||||
const navLink =
|
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';
|
'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>
|
</script>
|
||||||
|
|
||||||
{#snippet selectionActions()}
|
{#snippet selectionActions()}
|
||||||
@@ -230,130 +136,14 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if panelPhoto}
|
{#if panelPhoto}
|
||||||
{@const p = panelPhoto}
|
<PhotoPanel
|
||||||
{#if !photoId}{@render selectionActions()}{/if}
|
photo={panelPhoto}
|
||||||
<div>
|
{role}
|
||||||
<div class="p-4">
|
viewer={!!photoId}
|
||||||
{#if editingName}
|
onOpen={openSelected}
|
||||||
<form class="relative" onsubmit={saveName}>
|
onRenamed={onRenamed}
|
||||||
<input
|
onDeleted={onDeleted}
|
||||||
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}
|
|
||||||
>
|
|
||||||
{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}
|
{:else if !photoId && selectedItems.length > 1}
|
||||||
{@render selectionActions()}
|
{@render selectionActions()}
|
||||||
{:else if album && !photoId}
|
{:else if album && !photoId}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { transfer } from '$lib/stores/transfer.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 activeId = $derived($page.params.id ?? '');
|
||||||
const onTrash = $derived($page.url.pathname === '/trash');
|
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(() => {
|
$effect(() => {
|
||||||
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$page.url.pathname;
|
||||||
|
if (auth.isAuthenticated) trash.load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
@@ -79,6 +98,7 @@
|
|||||||
ondragover={(e) => onDragOver(e, album.id)}
|
ondragover={(e) => onDragOver(e, album.id)}
|
||||||
ondragleave={() => onDragLeave(album.id)}
|
ondragleave={() => onDragLeave(album.id)}
|
||||||
ondrop={(e) => onDrop(e, album.id)}
|
ondrop={(e) => onDrop(e, album.id)}
|
||||||
|
oncontextmenu={(e) => onContextMenu(e, album)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||||
@@ -95,6 +115,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">{album.title}</span>
|
<span class="truncate">{album.title}</span>
|
||||||
|
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -116,6 +137,7 @@
|
|||||||
<Icon name="delete" class="size-4 text-text-subtle" />
|
<Icon name="delete" class="size-4 text-text-subtle" />
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">Корзина</span>
|
<span class="truncate">Корзина</span>
|
||||||
|
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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;
|
onSelect?: (index: number, event: MouseEvent) => void;
|
||||||
onOpen?: (index: number) => void;
|
onOpen?: (index: number) => void;
|
||||||
onDragStart?: (index: number, event: DragEvent) => 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);
|
const isSelected = (id: string) => selectedIds.includes(id);
|
||||||
</script>
|
</script>
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
onclick={(e) => onSelect?.(i, e)}
|
onclick={(e) => onSelect?.(i, e)}
|
||||||
ondblclick={() => onOpen?.(i)}
|
ondblclick={() => onOpen?.(i)}
|
||||||
ondragstart={(e) => onDragStart?.(i, e)}
|
ondragstart={(e) => onDragStart?.(i, e)}
|
||||||
|
oncontextmenu={(e) => onContextMenu?.(i, e)}
|
||||||
>
|
>
|
||||||
{#if photo.thumb_url}
|
{#if photo.thumb_url}
|
||||||
<img
|
<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">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import Icon from './Icon.svelte';
|
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';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
index: number;
|
index: number;
|
||||||
canDelete?: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onIndexChange: (index: number) => 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 fresh = $state<Photo | null>(null);
|
||||||
let touchX = 0;
|
let touchX = 0;
|
||||||
@@ -98,9 +100,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const topClass = $derived(
|
const topClass = $derived(
|
||||||
isFullscreen
|
`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'}`
|
||||||
? `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'
|
|
||||||
);
|
);
|
||||||
const bottomClass = $derived(
|
const bottomClass = $derived(
|
||||||
isFullscreen
|
isFullscreen
|
||||||
@@ -288,22 +288,56 @@
|
|||||||
else if (dx < -50) next();
|
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 =
|
const btn =
|
||||||
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
|
'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>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
||||||
|
|
||||||
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
||||||
<div bind:this={topEl} class={topClass}>
|
<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}>
|
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||||
<Icon name="arrow_back" class="size-4" />
|
<Icon name="arrow_back" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="pointer-events-auto flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={btn}
|
class={btn}
|
||||||
@@ -316,29 +350,6 @@
|
|||||||
>
|
>
|
||||||
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
|
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -365,6 +376,7 @@
|
|||||||
onpointermove={onPointerMove}
|
onpointermove={onPointerMove}
|
||||||
onpointerup={onPointerUp}
|
onpointerup={onPointerUp}
|
||||||
onpointercancel={onPointerUp}
|
onpointercancel={onPointerUp}
|
||||||
|
oncontextmenu={onContextMenu}
|
||||||
ontouchstart={onTouchStart}
|
ontouchstart={onTouchStart}
|
||||||
ontouchend={onTouchEnd}
|
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"/>' },
|
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"/>' },
|
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"/>' },
|
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"/>' },
|
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"/>' },
|
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"/>' },
|
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;
|
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) {
|
upsert(album: Album) {
|
||||||
const i = this.list.findIndex((a) => a.id === album.id);
|
const i = this.list.findIndex((a) => a.id === album.id);
|
||||||
if (i === -1) {
|
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;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Photo {
|
export interface Photo {
|
||||||
id: string;
|
id: string;
|
||||||
album_id: string;
|
album_id: string;
|
||||||
@@ -50,6 +55,7 @@ export interface Photo {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
caption: string;
|
caption: string;
|
||||||
exif?: Record<string, unknown>;
|
exif?: Record<string, unknown>;
|
||||||
|
tags?: Tag[];
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
album_title?: string;
|
album_title?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import AlbumRail from '$lib/components/AlbumRail.svelte';
|
import AlbumRail from '$lib/components/AlbumRail.svelte';
|
||||||
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
||||||
import TransferDialog from '$lib/components/TransferDialog.svelte';
|
import TransferDialog from '$lib/components/TransferDialog.svelte';
|
||||||
|
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -21,3 +22,4 @@
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
||||||
<TransferDialog />
|
<TransferDialog />
|
||||||
|
<AlbumActionsDialogs />
|
||||||
|
|||||||
@@ -4,10 +4,23 @@
|
|||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { density } from '$lib/stores/density.svelte';
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import { auth } from '$lib/stores/auth.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(() => {
|
$effect(() => {
|
||||||
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
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>
|
</script>
|
||||||
|
|
||||||
{#if !auth.isAuthenticated}
|
{#if !auth.isAuthenticated}
|
||||||
@@ -44,7 +57,11 @@
|
|||||||
>
|
>
|
||||||
{#each albums.list as album (album.id)}
|
{#each albums.list as album (album.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href="/albums/{album.id}" class="group block">
|
<a
|
||||||
|
href="/albums/{album.id}"
|
||||||
|
class="group block"
|
||||||
|
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
|
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api, photosApi } from '$lib/api';
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import Uploader from '$lib/components/Uploader.svelte';
|
import Uploader from '$lib/components/Uploader.svelte';
|
||||||
import DensityControl from '$lib/components/DensityControl.svelte';
|
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { selection } from '$lib/stores/selection.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 { SSEClient, type SSEEvent } from '$lib/sse';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
@@ -21,6 +24,8 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
|
let pendingDelete = $state(false);
|
||||||
|
let deletingMenu = $state(false);
|
||||||
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
||||||
|
|
||||||
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
||||||
@@ -86,10 +91,12 @@
|
|||||||
} else {
|
} else {
|
||||||
photos = [photo, ...photos];
|
photos = [photo, ...photos];
|
||||||
}
|
}
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'photo.deleted': {
|
case 'photo.deleted': {
|
||||||
photos = photos.filter((p) => p.id !== data.id);
|
photos = photos.filter((p) => p.id !== data.id);
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'photo.updated': {
|
case 'photo.updated': {
|
||||||
@@ -177,6 +184,80 @@
|
|||||||
uploader?.upload(ev.dataTransfer.files);
|
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>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -245,6 +326,7 @@
|
|||||||
selectedIds={selection.albumId === albumId ? selection.ids : []}
|
selectedIds={selection.albumId === albumId ? selection.ids : []}
|
||||||
onSelect={selectPhoto}
|
onSelect={selectPhoto}
|
||||||
onDragStart={dragPhoto}
|
onDragStart={dragPhoto}
|
||||||
|
onContextMenu={openMenuForPhoto}
|
||||||
onOpen={(i) => {
|
onOpen={(i) => {
|
||||||
const p = photos[i];
|
const p = photos[i];
|
||||||
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
||||||
@@ -284,7 +366,19 @@
|
|||||||
for (const p of created) {
|
for (const p of created) {
|
||||||
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
||||||
}
|
}
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 { api } from '$lib/api';
|
||||||
import Viewer from '$lib/components/Viewer.svelte';
|
import Viewer from '$lib/components/Viewer.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.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';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
const albumId = $derived($page.params.id ?? '');
|
const albumId = $derived($page.params.id ?? '');
|
||||||
const photoId = $derived($page.params.photoId ?? '');
|
const photoId = $derived($page.params.photoId ?? '');
|
||||||
|
|
||||||
let photos = $state<Photo[]>([]);
|
let photos = $state<Photo[]>([]);
|
||||||
let role = $state('');
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let pendingDelete = $state<Photo | null>(null);
|
let pendingDelete = $state<Photo | null>(null);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
|
|
||||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
|
||||||
const index = $derived(photos.findIndex((p) => p.id === photoId));
|
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(() => {
|
$effect(() => {
|
||||||
const id = albumId;
|
const id = albumId;
|
||||||
@@ -32,9 +26,8 @@
|
|||||||
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
|
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
|
||||||
api<{ album: Album; role: string }>(`/albums/${id}`)
|
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||||
])
|
])
|
||||||
.then(([p, a]) => {
|
.then(([p]) => {
|
||||||
photos = p.photos;
|
photos = p.photos;
|
||||||
role = a.role;
|
|
||||||
})
|
})
|
||||||
.catch((e) => (error = (e as Error).message))
|
.catch((e) => (error = (e as Error).message))
|
||||||
.finally(() => (loading = false));
|
.finally(() => (loading = false));
|
||||||
@@ -49,9 +42,13 @@
|
|||||||
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
|
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removePhoto(photo: Photo) {
|
$effect(() => {
|
||||||
pendingDelete = photo;
|
const id = viewerActions.deleteRequested;
|
||||||
}
|
if (!id) return;
|
||||||
|
const photo = photos.find((p) => p.id === id);
|
||||||
|
if (photo) pendingDelete = photo;
|
||||||
|
viewerActions.clear();
|
||||||
|
});
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
const photo = pendingDelete;
|
const photo = pendingDelete;
|
||||||
@@ -92,14 +89,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<div class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
<Viewer
|
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
|
||||||
{photos}
|
|
||||||
{index}
|
|
||||||
{canDelete}
|
|
||||||
onClose={close}
|
|
||||||
onIndexChange={change}
|
|
||||||
onDelete={removePhoto}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.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';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
let photos = $state<Photo[]>([]);
|
let photos = $state<Photo[]>([]);
|
||||||
@@ -10,6 +12,13 @@
|
|||||||
let pending = $state<Photo | null>(null);
|
let pending = $state<Photo | null>(null);
|
||||||
let busy = $state(false);
|
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() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -26,14 +35,49 @@
|
|||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (restoring && !albums.loaded) albums.load();
|
||||||
|
});
|
||||||
|
|
||||||
async function restore(p: Photo) {
|
async function restore(p: Photo) {
|
||||||
try {
|
try {
|
||||||
await photosApi.restore(p.id);
|
await photosApi.restore(p.id);
|
||||||
photos = photos.filter((x) => x.id !== p.id);
|
photos = photos.filter((x) => x.id !== p.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.code === 'album_required') {
|
||||||
|
restoring = p;
|
||||||
|
targetId = '';
|
||||||
|
createNew = false;
|
||||||
|
newTitle = '';
|
||||||
|
restoreError = '';
|
||||||
|
} else {
|
||||||
error = (e as Error).message;
|
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() {
|
async function remove() {
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@@ -82,7 +126,9 @@
|
|||||||
<p class="text-text truncate text-xs" title={photo.original_filename}>
|
<p class="text-text truncate text-xs" title={photo.original_filename}>
|
||||||
{photo.original_filename}
|
{photo.original_filename}
|
||||||
</p>
|
</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">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -119,3 +165,103 @@
|
|||||||
onClose={() => (pending = null)}
|
onClose={() => (pending = null)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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 { onMount } from 'svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import IconSprite from '$lib/components/IconSprite.svelte';
|
import IconSprite from '$lib/components/IconSprite.svelte';
|
||||||
|
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { theme } from '$lib/stores/theme.svelte';
|
import { theme } from '$lib/stores/theme.svelte';
|
||||||
import { density } from '$lib/stores/density.svelte';
|
import { density } from '$lib/stores/density.svelte';
|
||||||
@@ -23,3 +24,4 @@
|
|||||||
|
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<ContextMenu />
|
||||||
|
|||||||
@@ -205,9 +205,60 @@ owner/admin) и загрузка в цель (member+).
|
|||||||
### 24. Компактный верхний хром
|
### 24. Компактный верхний хром
|
||||||
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
|
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
|
||||||
минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок,
|
минимуму: узкие вертикальные отступы (`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
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||||
|
|||||||
@@ -35,10 +35,19 @@
|
|||||||
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
|
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
|
||||||
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
|
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
|
||||||
фото есть команда бэкфилла метаданных из оригиналов.
|
фото есть команда бэкфилла метаданных из оригиналов.
|
||||||
|
- **Метки фото.** Пользователь присваивает фото свои метки: создаёт новые и выбирает из списка
|
||||||
|
с поиском; метки показываются и снимаются в разделе «Метки» панели фото.
|
||||||
|
- **Контекстные меню.** Правый клик по фото (в сетке и в просмотре) открывает меню с пунктами
|
||||||
|
раздела «Действия»; правый клик по альбому (в рейле и на обзоре) — меню «Переименовать»/
|
||||||
|
«Удалить».
|
||||||
|
- **Удаление альбома.** Удаление альбома помещает его фото в корзину, не удаляя объекты
|
||||||
|
хранилища; при восстановлении фото без альбома предлагается выбрать существующий альбом или
|
||||||
|
создать новый.
|
||||||
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
|
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
|
||||||
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
|
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
|
||||||
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
|
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
|
||||||
удаление текущего фото с модальным подтверждением; возврат к обзору.
|
действия просмотра (скачать, удалить, перенести, копировать) — в правой панели, в разделе
|
||||||
|
«Действия»; удаление с модальным подтверждением; возврат к обзору.
|
||||||
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
||||||
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
||||||
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
|
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
|
||||||
@@ -55,7 +64,8 @@
|
|||||||
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
|
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
|
||||||
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
|
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
|
||||||
обзоре убрана пустая правая панель.
|
обзоре убрана пустая правая панель.
|
||||||
- **Счётчики.** В ответе альбома — число фото и участников.
|
- **Счётчики.** В ответе альбома — число фото и участников; в рейле у каждого альбома показано
|
||||||
|
число фото, у корзины — число удалённых фото.
|
||||||
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
|
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
|
||||||
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
|
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
|
||||||
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
|
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
|
||||||
@@ -72,9 +82,10 @@
|
|||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
|
|
||||||
- `albums`: отображение обложки, счётчики фото/участников и явный выбор обложки.
|
- `albums`: отображение обложки, счётчики фото/участников, явный выбор обложки и удаление в
|
||||||
|
корзину.
|
||||||
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
|
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
|
||||||
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование и корзина.
|
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование, корзина и метки.
|
||||||
- `auth`: хранение пользовательских настроек отображения информации в профиле.
|
- `auth`: хранение пользовательских настроек отображения информации в профиле.
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
@@ -89,7 +100,11 @@
|
|||||||
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
|
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
|
||||||
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
|
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
|
||||||
`models.User.Preferences`, `internal/auth` (`Me` отдаёт настройки,
|
`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`
|
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
|
||||||
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
|
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
|
||||||
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
|
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
|
||||||
@@ -97,7 +112,9 @@
|
|||||||
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
|
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
|
||||||
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
|
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
|
||||||
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
|
новые значения токенов темы и стили скроллбаров; `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` не
|
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
|
||||||
затрагивается — иконки по-прежнему наследуют цвет темы.
|
затрагивается — иконки по-прежнему наследуют цвет темы.
|
||||||
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
|
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
|
||||||
|
|||||||
@@ -87,3 +87,32 @@
|
|||||||
#### Scenario: Окончательное удаление при наличии ссылок
|
#### Scenario: Окончательное удаление при наличии ссылок
|
||||||
- **WHEN** на объект хранилища ссылается копия в другом альбоме
|
- **WHEN** на объект хранилища ссылается копия в другом альбоме
|
||||||
- **THEN** запись удаляется, а объект хранилища сохраняется
|
- **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** пользователь открывает обзор альбомов
|
- **WHEN** пользователь открывает обзор альбомов
|
||||||
- **THEN** правая панель не отображается
|
- **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: Компактный верхний хром
|
### Requirement: Компактный верхний хром
|
||||||
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
|
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
|
||||||
(узкие отступы, иконочные органы управления), сохраняя все действия доступными.
|
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
|
||||||
|
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
|
||||||
|
увеличивая область фото.
|
||||||
|
|
||||||
#### Scenario: Компактные верхние панели
|
#### Scenario: Компактные верхние панели
|
||||||
- **WHEN** открыт список альбомов или режим просмотра фото
|
- **WHEN** открыт список альбомов или режим просмотра фото
|
||||||
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
|
- **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.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.16 Раздел «Действия» в панели — пункты-надписи с иконками (не кнопки): «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; надпись «Выбрано:» убрана. Проверка: Playwright — «Открыть (просмотр)» открывает просмотр, раздел присутствует (4/4).
|
||||||
- [x] 9.17 Компактный верхний хром: верхняя панель приложения и верхняя панель режима просмотра сведены к минимуму (узкие отступы, иконочные кнопки `size-4`, компактный переключатель темы). Проверка: высота `<header>` ~31px, верхней панели просмотра ~29px; `npm run check`.
|
- [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