feat: context menus, unified photo panel, tags, album trash and counters

- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count
- frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs
- openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
This commit is contained in:
iu
2026-10-10 22:45:41 +03:00
parent 2d6a3fea1a
commit 61b45b2742
37 changed files with 1639 additions and 328 deletions
+3 -3
View File
@@ -150,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
if role != roles.Owner {
return httpx.Forbidden("forbidden", "only the owner can delete the album")
}
if err := s.store.Delete(ctx, a.ID); err != nil {
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
return err
}
if s.storage != nil {
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
if err := s.store.Delete(ctx, a.ID); err != nil {
return err
}
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
return nil
+8
View File
@@ -107,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
}
return nil
}
// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
// or permanently removed from the trash after the album is gone.
func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
return err
}
+4
View File
@@ -139,11 +139,15 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/restore", photosHandler.Restore)
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
authed.POST("/photos/:id/tags", photosHandler.AddTag)
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
authed.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
authed.GET("/trash", photosHandler.Trash)
authed.GET("/trash/count", photosHandler.TrashCount)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
+6
View File
@@ -78,6 +78,7 @@ type Photo struct {
Longitude *float64 `json:"longitude,omitempty"`
Caption string `json:"caption"`
EXIF map[string]any `json:"exif,omitempty"`
Tags []Tag `json:"tags,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
AlbumTitle string `json:"album_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -91,6 +92,11 @@ type Photo struct {
DownloadURL string `json:"download_url,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Comment struct {
ID string `json:"id"`
PhotoID string `json:"photo_id"`
+48 -1
View File
@@ -143,7 +143,11 @@ func (h *Handler) PermanentDelete(c *gin.Context) {
}
func (h *Handler) Restore(c *gin.Context) {
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c))
var req struct {
AlbumID string `json:"album_id"`
}
_ = c.ShouldBindJSON(&req)
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
@@ -160,6 +164,49 @@ func (h *Handler) Trash(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photos": list})
}
func (h *Handler) TrashCount(c *gin.Context) {
n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"count": n})
}
func (h *Handler) Tags(c *gin.Context) {
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
func (h *Handler) AddTag(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
return
}
p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) RemoveTag(c *gin.Context) {
p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
type batchRequest struct {
IDs []string `json:"ids" binding:"required"`
AlbumID string `json:"album_id"`
+109 -15
View File
@@ -339,7 +339,10 @@ func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string)
}
// Restore returns a photo from the trash back to its album.
func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.Photo, error) {
// Restore returns a photo from the trash back to an album. When targetAlbumID is
// empty the photo goes back to its own album; if that album is gone, an
// album_required error is returned so the client can prompt for a target.
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
@@ -347,24 +350,52 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
}
return nil, err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
if targetAlbumID != "" {
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return nil, err
}
if !roles.CanUpload(role) {
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
return nil, err
}
p.AlbumID = targetAlbumID
p.DeletedAt = nil
} else {
if p.AlbumID == "" {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
if err != nil {
return nil, err
}
if !exists {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
}
if p.DeletedAt != nil {
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
return nil, err
}
}
p.DeletedAt = nil
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
}
if p.DeletedAt == nil {
return p, nil
}
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
return nil, err
}
p.DeletedAt = nil
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
s.hub.Publish(p.AlbumID, "photo.created", p)
if p.AlbumID != "" {
s.hub.Publish(p.AlbumID, "photo.created", p)
}
return p, nil
}
@@ -492,3 +523,66 @@ func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, err
}
return out, nil
}
// TrashCount returns the number of photos in the user's trash.
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
return s.store.TrashCount(ctx, userID)
}
// Tags lists the user's tags, optionally filtered by a search query.
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
return s.store.ListTags(ctx, userID, q)
}
func (s *Service) taggablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if p.AlbumID == "" {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
return p, nil
}
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
tag, err := s.store.EnsureTag(ctx, userID, name)
if err != nil {
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
}
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
// RemoveTag detaches one of the user's tags from a photo.
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
if err != nil {
return nil, err
}
if !owned {
return nil, httpx.NotFound("tag_not_found", "tag not found")
}
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
+140 -13
View File
@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
"shared-album/internal/models"
)
@@ -21,7 +23,7 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.mime_type,
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
@@ -29,17 +31,20 @@ const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at`
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at,
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
var exifText string
var exifText, tagsText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt)
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -47,9 +52,20 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
return &p, nil
}
func setTags(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "[]" {
return
}
var tags []models.Tag
if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
p.Tags = tags
}
}
func setExif(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "{}" {
return
@@ -118,16 +134,17 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText string
var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt); err != nil {
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
@@ -199,16 +216,20 @@ func (s *Store) HardDelete(ctx context.Context, id string) error {
return nil
}
// ListTrash returns soft-deleted photos from albums the user belongs to.
// ListTrash returns soft-deleted photos from albums the user belongs to, plus
// their own photos whose album no longer exists.
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`, a.title
`SELECT `+photoColumns+`, COALESCE(a.title, '')
FROM photos p
JOIN users u ON u.id = p.uploader_id
JOIN albums a ON a.id = p.album_id
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)
ORDER BY p.deleted_at DESC
LIMIT 200`,
userID, userID)
@@ -220,21 +241,127 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText string
var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle); err != nil {
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
// TrashCount counts soft-deleted photos accessible to the user.
func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
var n int
err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM photos p
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)`, userID).Scan(&n)
return n, err
}
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AlbumExists reports whether an album row exists.
func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
return ok, err
}
func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, name FROM tags
WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $2 || '%')
ORDER BY lower(name) LIMIT 50`, userID, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Tag{}
for rows.Next() {
var t models.Tag
if err := rows.Scan(&t.ID, &t.Name); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// EnsureTag returns the user's tag with the given name, creating it if needed.
func (s *Store) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, ErrNotFound
}
var t models.Tag
err := s.pool.QueryRow(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name)
if err == nil {
return &t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
id := auth.NewID()
err = s.pool.QueryRow(ctx,
`INSERT INTO tags (id, user_id, name) VALUES ($1,$2,$3) RETURNING id, name`, id, userID, name).
Scan(&t.ID, &t.Name)
if err != nil {
if e2 := s.pool.QueryRow(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name); e2 == nil {
return &t, nil
}
return nil, err
}
return &t, nil
}
func (s *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
return err
}
func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
return err
}
func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
return ok, err
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
@@ -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
View File
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte';
import type { Photo, Preferences } from './types';
import type { Photo, Preferences, Tag } from './types';
export class ApiError extends Error {
status: number;
@@ -120,6 +120,19 @@ export const photosApi = {
body: { ids, album_id: albumId }
}),
trash: () => api<{ photos: Photo[] }>('/trash'),
restore: (id: string) => api<{ photo: Photo }>(`/photos/${id}/restore`, { method: 'POST' }),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' })
trashCount: () => api<{ count: number }>('/trash/count'),
restore: (id: string, albumId?: string) =>
api<{ photo: Photo }>(`/photos/${id}/restore`, {
method: 'POST',
body: albumId ? { album_id: albumId } : {}
}),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
addTag: (id: string, name: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
removeTag: (id: string, tagId: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags/${tagId}`, { method: 'DELETE' })
};
export const tagsApi = {
list: (q = '') => api<{ tags: Tag[] }>(`/tags${q ? `?q=${encodeURIComponent(q)}` : ''}`)
};
@@ -0,0 +1,119 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
let title = $state('');
let busy = $state(false);
let error = $state('');
$effect(() => {
const a = albumActions.renaming;
if (a) {
title = a.title;
error = '';
}
});
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
async function saveRename(ev: Event) {
ev.preventDefault();
const a = albumActions.renaming;
if (!a || !title.trim() || busy) return;
busy = true;
error = '';
try {
await albums.rename(a.id, title.trim());
albumActions.renaming = null;
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function confirmDelete() {
const a = albumActions.deleting;
if (!a) return;
busy = true;
error = '';
try {
await albums.destroy(a.id);
albumActions.deleting = null;
if ($page.params.id === a.id) await goto('/');
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if albumActions.renaming}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Переименовать альбом"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !busy) albumActions.renaming = null;
}}
>
<form
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
onsubmit={saveRename}
>
<h2 class="text-text text-lg font-semibold">Переименовать альбом</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Название</span>
<input
bind:value={title}
use:autofocus
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => (albumActions.renaming = null)}
>
Отмена
</button>
<button
type="submit"
disabled={busy || !title.trim()}
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
<Icon name="check" class="size-4" />
{busy ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</form>
</div>
{/if}
{#if albumActions.deleting}
<ConfirmDialog
title="Удалить альбом?"
message="Альбом «{albumActions.deleting.title}» будет удалён."
confirmLabel="Удалить"
busy={busy}
onConfirm={confirmDelete}
onClose={() => (albumActions.deleting = null)}
/>
{/if}
+17 -227
View File
@@ -4,16 +4,13 @@
import { api, photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import CoverPicker from './CoverPicker.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import PhotoPanel from './PhotoPanel.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
import type { Album, Photo, Preferences } from '$lib/types';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
@@ -24,40 +21,13 @@
let picking = $state(false);
let photo = $state<Photo | null>(null);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(
!!panelPhoto && (canEdit || panelPhoto.uploader_id === auth.user?.id)
);
const exif = $derived((panelPhoto?.exif ?? {}) as Record<string, unknown>);
const prefs = $derived(auth.preferences);
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
const visiblePrefs = $derived(prefs.photo_info?.visible);
const effectiveVisible = $derived(
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
);
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
const attributeKeys = $derived(
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
)
);
let settingsOpen = $state(false);
let showAll = $state(false);
let savingPrefs = $state(false);
let pendingDeleteSelected = $state(false);
let deletingSelected = $state(false);
@@ -79,21 +49,13 @@
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
}
async function savePreferences(next: Preferences) {
savingPrefs = true;
try {
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
body: next
});
auth.setPreferences(res.preferences ?? {});
showAll = false;
settingsOpen = false;
} catch (e) {
error = (e as Error).message;
} finally {
savingPrefs = false;
}
function onRenamed(updated: Photo) {
photo = updated;
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
}
function onDeleted() {
selection.clear();
}
$effect(() => {
@@ -115,9 +77,6 @@
$effect(() => {
const id = photoId;
editingName = false;
nameError = '';
showAll = false;
if (!id) {
photo = null;
return;
@@ -127,58 +86,6 @@
.catch((e) => (error = (e as Error).message));
});
$effect(() => {
// сброс временного состояния панели при выборе другого фото в сетке
selection.ids.join(',');
editingName = false;
nameError = '';
showAll = false;
});
function startEdit() {
if (!panelPhoto) return;
nameDraft = panelPhoto.original_filename;
nameError = '';
editingName = true;
}
async function saveName(ev: Event) {
ev.preventDefault();
if (!panelPhoto || !nameDraft.trim()) return;
savingName = true;
nameError = '';
try {
const data = await api<{ photo: Photo }>(`/photos/${panelPhoto.id}`, {
method: 'PATCH',
body: { original_filename: nameDraft.trim() }
});
photo = data.photo;
if (selectedItems.some((p) => p.id === data.photo.id)) selection.replace(data.photo);
editingName = false;
} catch (e) {
nameError = (e as Error).message;
} finally {
savingName = false;
}
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
async function resetCover() {
if (!album) return;
try {
@@ -190,7 +97,6 @@
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
</script>
{#snippet selectionActions()}
@@ -230,130 +136,14 @@
{/snippet}
{#if panelPhoto}
{@const p = panelPhoto}
{#if !photoId}{@render selectionActions()}{/if}
<div>
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
bind:value={nameDraft}
use:autofocus
aria-label="Имя файла"
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
/>
<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}
<PhotoPanel
photo={panelPhoto}
{role}
viewer={!!photoId}
onOpen={openSelected}
onRenamed={onRenamed}
onDeleted={onDeleted}
/>
{:else if !photoId && selectedItems.length > 1}
{@render selectionActions()}
{:else if album && !photoId}
@@ -6,6 +6,10 @@
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { trash } from '$lib/stores/trash.svelte';
import type { Album } from '$lib/types';
const activeId = $derived($page.params.id ?? '');
const onTrash = $derived($page.url.pathname === '/trash');
@@ -36,9 +40,24 @@
}
}
function onContextMenu(ev: MouseEvent, album: Album) {
ev.preventDefault();
ev.stopPropagation();
const items: ContextMenuItem[] = [
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
];
contextMenu.show(ev.clientX, ev.clientY, items);
}
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
$effect(() => {
$page.url.pathname;
if (auth.isAuthenticated) trash.load();
});
</script>
<div class="flex h-full flex-col">
@@ -79,6 +98,7 @@
ondragover={(e) => onDragOver(e, album.id)}
ondragleave={() => onDragLeave(album.id)}
ondrop={(e) => onDrop(e, album.id)}
oncontextmenu={(e) => onContextMenu(e, album)}
>
<span
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
@@ -95,6 +115,7 @@
{/if}
</span>
<span class="truncate">{album.title}</span>
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
</a>
</li>
{/each}
@@ -116,6 +137,7 @@
<Icon name="delete" class="size-4 text-text-subtle" />
</span>
<span class="truncate">Корзина</span>
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
</a>
</div>
{/if}
@@ -0,0 +1,63 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { contextMenu } from '$lib/stores/context-menu.svelte';
const width = 200;
const itemH = 34;
const style = $derived(
(() => {
const pad = 8;
let x = contextMenu.x;
let y = contextMenu.y;
if (typeof window !== 'undefined') {
x = Math.min(x, window.innerWidth - width - pad);
y = Math.min(y, window.innerHeight - contextMenu.items.length * itemH - pad);
}
return `left: ${Math.max(pad, x)}px; top: ${Math.max(pad, y)}px; min-width: ${width}px;`;
})()
);
function run(item: (typeof contextMenu.items)[number]) {
contextMenu.close();
item.run();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') contextMenu.close();
}
</script>
<svelte:window
onkeydown={onKey}
onresize={() => contextMenu.close()}
onscroll={() => contextMenu.close()}
onclick={() => contextMenu.close()}
oncontextmenu={() => contextMenu.close()}
/>
{#if contextMenu.open}
<div
use:portal
class="border-border bg-bg-elevated fixed z-[70] overflow-hidden rounded-lg border py-1 shadow-lg"
style={style}
role="menu"
tabindex="-1"
oncontextmenu={(e) => e.preventDefault()}
>
{#each contextMenu.items as item (item.label)}
<button
type="button"
role="menuitem"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm {item.danger
? 'text-danger hover:bg-bg-muted'
: 'text-text hover:bg-bg-muted'}"
onclick={() => run(item)}
>
<Icon name={item.icon} class="size-4" />
{item.label}
</button>
{/each}
</div>
{/if}
+4 -1
View File
@@ -9,9 +9,11 @@
onSelect?: (index: number, event: MouseEvent) => void;
onOpen?: (index: number) => void;
onDragStart?: (index: number, event: DragEvent) => void;
onContextMenu?: (index: number, event: MouseEvent) => void;
}
let { photos, selectedIds = [], onSelect, onOpen, onDragStart }: Props = $props();
let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
$props();
const isSelected = (id: string) => selectedIds.includes(id);
</script>
@@ -37,6 +39,7 @@
onclick={(e) => onSelect?.(i, e)}
ondblclick={() => onOpen?.(i)}
ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)}
>
{#if photo.thumb_url}
<img
@@ -0,0 +1,452 @@
<script lang="ts">
import { api, photosApi, tagsApi } from '$lib/api';
import Icon from './Icon.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
import type { Photo, Preferences, Tag } from '$lib/types';
interface Props {
photo: Photo;
role: string;
viewer?: boolean;
onOpen?: () => void;
onRenamed?: (photo: Photo) => void;
onDeleted?: () => void;
}
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
let pendingDelete = $state(false);
let deleting = $state(false);
let settingsOpen = $state(false);
let showAll = $state(false);
let savingPrefs = $state(false);
const prefs = $derived(auth.preferences);
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
const visiblePrefs = $derived(prefs.photo_info?.visible);
const effectiveVisible = $derived(
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
);
const exif = $derived((photo.exif ?? {}) as Record<string, unknown>);
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
const attributeKeys = $derived(
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
)
);
$effect(() => {
// сброс временного состояния при смене фото
photo.id;
editingName = false;
nameError = '';
showAll = false;
tagQuery = '';
tagOpen = false;
});
let tagQuery = $state('');
let tagOptions = $state<Tag[]>([]);
let tagOpen = $state(false);
let tagBusy = $state(false);
const attachedTagIds = $derived(new Set((photo.tags ?? []).map((t) => t.id)));
const tagSuggestions = $derived(
tagOptions.filter((t) => !attachedTagIds.has(t.id))
);
$effect(() => {
const q = tagQuery;
if (!tagOpen) return;
const handle = setTimeout(async () => {
try {
tagOptions = (await tagsApi.list(q)).tags;
} catch {
tagOptions = [];
}
}, 180);
return () => clearTimeout(handle);
});
async function addTag(name: string) {
const n = name.trim();
if (!n || tagBusy) return;
tagBusy = true;
try {
const res = await photosApi.addTag(photo.id, n);
onRenamed?.(res.photo);
tagQuery = '';
tagOpen = false;
} catch {
/* ignore */
} finally {
tagBusy = false;
}
}
async function removeTag(tagId: string) {
try {
const res = await photosApi.removeTag(photo.id, tagId);
onRenamed?.(res.photo);
} catch {
/* ignore */
}
}
function startEdit() {
nameDraft = photo.original_filename;
nameError = '';
editingName = true;
}
async function saveName(ev: Event) {
ev.preventDefault();
if (!nameDraft.trim()) return;
savingName = true;
nameError = '';
try {
const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
method: 'PATCH',
body: { original_filename: nameDraft.trim() }
});
onRenamed?.(data.photo);
editingName = false;
} catch (e) {
nameError = (e as Error).message;
} finally {
savingName = false;
}
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
async function savePreferences(next: Preferences) {
savingPrefs = true;
try {
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
body: next
});
auth.setPreferences(res.preferences ?? {});
showAll = false;
settingsOpen = false;
} catch {
/* ignore */
} finally {
savingPrefs = false;
}
}
function requestDelete() {
if (viewer) {
viewerActions.requestDelete(photo.id);
} else {
pendingDelete = true;
}
}
async function confirmDelete() {
deleting = true;
try {
await photosApi.deleteBatch([photo.id]);
pendingDelete = false;
onDeleted?.();
} catch {
/* ignore */
} finally {
deleting = false;
}
}
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
</script>
<div>
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
bind:value={nameDraft}
use:autofocus
aria-label="Имя файла"
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
/>
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
<button
type="submit"
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
aria-label="Сохранить"
title="Сохранить"
disabled={savingName || !nameDraft.trim()}
>
<Icon name="check" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label="Отмена"
title="Отмена"
onclick={() => (editingName = false)}
>
<Icon name="close" class="size-4" />
</button>
</div>
</form>
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
{:else if canRename}
<button
type="button"
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
title="Переименовать"
onclick={startEdit}
>
{photo.original_filename}
</button>
{:else}
<h2 class="text-text truncate text-sm font-semibold">{photo.original_filename}</h2>
{/if}
</div>
<PanelSection id="photo-actions" title="Действия">
<nav class="space-y-0.5">
{#if !viewer}
<button type="button" class={navLink} onclick={() => onOpen?.()}>
<Icon name="image" class="size-4" />
Открыть (просмотр)
</button>
{/if}
{#if photo.download_url}
<a class={navLink} href={photo.download_url} target="_blank" rel="noreferrer" download>
<Icon name="download" class="size-4" />
Скачать
</a>
{/if}
<button
type="button"
class={navLink}
onclick={() => transfer.start([photo.id], null, 'move')}
>
<Icon name="arrow_back" class="size-4" />
Переместить
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start([photo.id], null, 'copy')}
>
<Icon name="content_copy" class="size-4" />
Копировать
</button>
{#if canDelete}
<button
type="button"
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
onclick={requestDelete}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{/if}
</nav>
</PanelSection>
<PanelSection id="photo-tags" title="Метки">
<div class="space-y-2">
{#if (photo.tags ?? []).length}
<div class="flex flex-wrap gap-1.5">
{#each photo.tags ?? [] as tag (tag.id)}
<span
class="border-border bg-bg-muted text-text inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
>
{tag.name}
<button
type="button"
class="text-text-muted hover:text-danger inline-flex"
aria-label="Убрать метку {tag.name}"
title="Убрать"
onclick={() => removeTag(tag.id)}
>
<Icon name="close" class="size-3" />
</button>
</span>
{/each}
</div>
{/if}
<div class="relative">
<input
bind:value={tagQuery}
onfocus={() => (tagOpen = true)}
oninput={() => (tagOpen = true)}
onblur={() => setTimeout(() => (tagOpen = false), 150)}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagQuery);
} else if (e.key === 'Escape') {
tagOpen = false;
}
}}
placeholder="Добавить метку…"
maxlength="64"
aria-label="Метки фото"
class="border-border bg-bg text-text w-full rounded-md border px-2 py-1 text-sm"
/>
{#if tagOpen && (tagSuggestions.length > 0 || tagQuery.trim())}
<div
class="border-border bg-bg-elevated absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border py-1 shadow-lg"
>
{#if tagQuery.trim()}
<button
type="button"
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
onmousedown={(e) => {
e.preventDefault();
addTag(tagQuery);
}}
>
<Icon name="add" class="size-4" />
Создать «{tagQuery.trim()}»
</button>
{/if}
{#each tagSuggestions as tag (tag.id)}
<button
type="button"
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
onmousedown={(e) => {
e.preventDefault();
addTag(tag.name);
}}
>
<Icon name="label" class="size-4 text-text-subtle" />
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</PanelSection>
<PanelSection id="photo-info" title="Информация">
{#snippet actions()}
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
aria-label="Настройки атрибутов"
title="Настройки атрибутов"
onclick={(e) => {
e.stopPropagation();
settingsOpen = true;
}}
>
<Icon name="settings" class="size-4" />
</button>
{/snippet}
<dl class="space-y-1">
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
<div class={metaRow}>
<span>Разрешение</span>
<span class="text-text">{photo.width}×{photo.height}</span>
</div>
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
<div class={metaRow}>
<span>{row.label}</span>
<span class="text-text">{row.value}</span>
</div>
{/each}
{#if hiddenCount > 0 && !showAll}
<button
type="button"
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
aria-label="Показать все атрибуты"
title="Показать все атрибуты"
onclick={() => (showAll = true)}
>
…
</button>
{/if}
{#if photo.taken_at}
<div class={metaRow}>
<span>Снято</span>
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
</div>
{/if}
{#if photo.latitude != null && photo.longitude != null}
<div class={metaRow}>
<span>Координаты</span>
<span class="text-text">{photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}</span>
</div>
{/if}
<div class={metaRow}>
<span>Загружено</span>
<span class="text-text">{new Date(photo.created_at).toLocaleString()}</span>
</div>
</dl>
</PanelSection>
<PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={photo.id} />
</PanelSection>
</div>
{#if settingsOpen}
<PhotoInfoSettingsDialog
attributes={attributeKeys}
defaults={EXIF_LABELS}
visible={[...effectiveVisible]}
labels={prefs.photo_info?.labels ?? {}}
busy={savingPrefs}
onSave={savePreferences}
onReset={() => savePreferences({})}
onClose={() => (settingsOpen = false)}
/>
{/if}
{#if pendingDelete}
<ConfirmDialog
title="Удалить фото?"
message={photo.original_filename}
confirmLabel="Удалить"
busy={deleting}
onConfirm={confirmDelete}
onClose={() => (pendingDelete = false)}
/>
{/if}
+44 -32
View File
@@ -1,19 +1,21 @@
<script lang="ts">
import { tick } from 'svelte';
import { api } from '$lib/api';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Photo } from '$lib/types';
interface Props {
photos: Photo[];
index: number;
canDelete?: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
onDelete?: (photo: Photo) => void;
}
let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props();
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
@@ -98,9 +100,7 @@
});
const topClass = $derived(
isFullscreen
? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 border-b px-2 py-0.5 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 items-center justify-between gap-1 border-b px-2 py-0.5'
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
);
const bottomClass = $derived(
isFullscreen
@@ -288,22 +288,56 @@
else if (dx < -50) next();
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const p = current;
if (!p) return;
const items: ContextMenuItem[] = [
...(p.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(p.download_url, '_blank')
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([p.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([p.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => viewerActions.requestDelete(p.id)
}
];
contextMenu.show(e.clientX, e.clientY, items);
}
const btn =
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
const dangerBtn = 'text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1';
</script>
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
<div bind:this={topEl} class={topClass}>
<div class="flex items-center gap-1">
<div class="pointer-events-auto flex items-center gap-1">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" class="size-4" />
</button>
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
</div>
<div class="flex items-center gap-1">
<div class="pointer-events-auto flex items-center gap-1">
<button
type="button"
class={btn}
@@ -316,29 +350,6 @@
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
{#if fresh?.download_url}
<a
href={fresh.download_url}
class={btn}
target="_blank"
rel="noreferrer"
aria-label="Скачать"
title="Скачать"
>
<Icon name="download" class="size-4" />
</a>
{/if}
{#if canDelete}
<button
type="button"
class={dangerBtn}
aria-label="Удалить фото"
title="Удалить фото"
onclick={() => current && onDelete?.(current)}
>
<Icon name="delete" class="size-4" />
</button>
{/if}
</div>
</div>
@@ -365,6 +376,7 @@
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
+2
View File
@@ -41,6 +41,8 @@ export const icons = {
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' },
edit: { path: '<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83l3.75 3.75z"/>' },
label: { path: '<path fill="currentColor" d="M17.63 5.84C17.27 5.33 16.67 5 16 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h11c.67 0 1.27-.33 1.63-.84L22 12z"/>' },
menu: { path: '<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>' },
grid_view: { path: '<path fill="currentColor" d="M3 3v8h8V3zm10 0v8h8V3zM3 13v8h8v-8zm10 0v8h8v-8z"/>' },
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' },
@@ -0,0 +1,21 @@
import type { Album } from '$lib/types';
class AlbumActionsStore {
renaming = $state<Album | null>(null);
deleting = $state<Album | null>(null);
openRename(album: Album) {
this.renaming = album;
}
openDelete(album: Album) {
this.deleting = album;
}
close() {
this.renaming = null;
this.deleting = null;
}
}
export const albumActions = new AlbumActionsStore();
+14
View File
@@ -40,6 +40,20 @@ class AlbumsStore {
return data.album;
}
async rename(id: string, title: string): Promise<Album> {
const data = await api<{ album: Album }>(`/albums/${id}`, {
method: 'PATCH',
body: { title }
});
this.upsert(data.album);
return data.album;
}
async destroy(id: string): Promise<void> {
await api(`/albums/${id}`, { method: 'DELETE' });
this.remove(id);
}
upsert(album: Album) {
const i = this.list.findIndex((a) => a.id === album.id);
if (i === -1) {
@@ -0,0 +1,30 @@
import type { IconName } from '$lib/icons/registry';
export interface ContextMenuItem {
label: string;
icon: IconName;
danger?: boolean;
run: () => void;
}
class ContextMenuStore {
open = $state(false);
x = $state(0);
y = $state(0);
items = $state<ContextMenuItem[]>([]);
show(x: number, y: number, items: ContextMenuItem[]) {
if (items.length === 0) return;
this.x = x;
this.y = y;
this.items = items;
this.open = true;
}
close() {
this.open = false;
this.items = [];
}
}
export const contextMenu = new ContextMenuStore();
+24
View File
@@ -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();
+6
View File
@@ -36,6 +36,11 @@ export interface AlbumMember {
user: User;
}
export interface Tag {
id: string;
name: string;
}
export interface Photo {
id: string;
album_id: string;
@@ -50,6 +55,7 @@ export interface Photo {
longitude?: number | null;
caption: string;
exif?: Record<string, unknown>;
tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
created_at: string;
+2
View File
@@ -4,6 +4,7 @@
import AlbumRail from '$lib/components/AlbumRail.svelte';
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
let { children } = $props();
@@ -21,3 +22,4 @@
</AppShell>
<TransferDialog />
<AlbumActionsDialogs />
+18 -1
View File
@@ -4,10 +4,23 @@
import { albums } from '$lib/stores/albums.svelte';
import { density } from '$lib/stores/density.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import type { Album } from '$lib/types';
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
ev.preventDefault();
ev.stopPropagation();
const items: ContextMenuItem[] = [
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
];
contextMenu.show(ev.clientX, ev.clientY, items);
}
</script>
{#if !auth.isAuthenticated}
@@ -44,7 +57,11 @@
>
{#each albums.list as album (album.id)}
<li>
<a href="/albums/{album.id}" class="group block">
<a
href="/albums/{album.id}"
class="group block"
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
>
<div
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
>
@@ -1,14 +1,17 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { api, photosApi } from '$lib/api';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Uploader from '$lib/components/Uploader.svelte';
import DensityControl from '$lib/components/DensityControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
@@ -21,6 +24,8 @@
let loading = $state(true);
let error = $state('');
let dragging = $state(false);
let pendingDelete = $state(false);
let deletingMenu = $state(false);
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
@@ -86,10 +91,12 @@
} else {
photos = [photo, ...photos];
}
loadAlbum().catch(() => {});
break;
}
case 'photo.deleted': {
photos = photos.filter((p) => p.id !== data.id);
loadAlbum().catch(() => {});
break;
}
case 'photo.updated': {
@@ -177,6 +184,80 @@
uploader?.upload(ev.dataTransfer.files);
}
}
function openMenuForPhoto(i: number, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const photo = photos[i];
if (!photo) return;
const inSel = selection.albumId === albumId && selection.has(photo.id);
const multi = inSel && selection.count > 1;
if (!inSel) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
const openFirst = () => {
const first = selection.albumId === albumId ? selection.items[0] : photo;
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
};
let items: ContextMenuItem[];
if (multi) {
const ids = selection.ids;
items = [
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
{ label: 'Переместить', icon: 'arrow_back', run: () => transfer.start(ids, null, 'move') },
{ label: 'Копировать', icon: 'content_copy', run: () => transfer.start(ids, null, 'copy') },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => (pendingDelete = true) }
];
} else {
items = [
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
...(photo.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(photo.download_url, '_blank')
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([photo.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([photo.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
pendingDelete = true;
}
}
];
}
contextMenu.show(e.clientX, e.clientY, items);
}
async function confirmMenuDelete() {
deletingMenu = true;
try {
await photosApi.deleteBatch(selection.ids);
selection.clear();
pendingDelete = false;
} catch (e) {
error = (e as Error).message;
} finally {
deletingMenu = false;
}
}
</script>
{#if loading}
@@ -245,6 +326,7 @@
selectedIds={selection.albumId === albumId ? selection.ids : []}
onSelect={selectPhoto}
onDragStart={dragPhoto}
onContextMenu={openMenuForPhoto}
onOpen={(i) => {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
@@ -284,7 +366,19 @@
for (const p of created) {
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
}
loadAlbum().catch(() => {});
}}
/>
</div>
{/if}
{#if pendingDelete}
<ConfirmDialog
title="Удалить выбранные фото?"
message="{selection.count} фото будут перемещены в корзину."
confirmLabel="Удалить"
busy={deletingMenu}
onConfirm={confirmMenuDelete}
onClose={() => (pendingDelete = false)}
/>
{/if}
@@ -4,25 +4,19 @@
import { api } from '$lib/api';
import Viewer from '$lib/components/Viewer.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
let photos = $state<Photo[]>([]);
let role = $state('');
let loading = $state(true);
let error = $state('');
let pendingDelete = $state<Photo | null>(null);
let deleting = $state(false);
const canEdit = $derived(role === 'owner' || role === 'admin');
const index = $derived(photos.findIndex((p) => p.id === photoId));
const currentPhoto = $derived(index >= 0 ? photos[index] : null);
const canDelete = $derived(
canEdit || (!!currentPhoto && currentPhoto.uploader_id === auth.user?.id)
);
$effect(() => {
const id = albumId;
@@ -32,9 +26,8 @@
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
api<{ album: Album; role: string }>(`/albums/${id}`)
])
.then(([p, a]) => {
.then(([p]) => {
photos = p.photos;
role = a.role;
})
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
@@ -49,9 +42,13 @@
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
}
async function removePhoto(photo: Photo) {
pendingDelete = photo;
}
$effect(() => {
const id = viewerActions.deleteRequested;
if (!id) return;
const photo = photos.find((p) => p.id === id);
if (photo) pendingDelete = photo;
viewerActions.clear();
});
async function confirmDelete() {
const photo = pendingDelete;
@@ -92,14 +89,7 @@
{:else}
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1">
<Viewer
{photos}
{index}
{canDelete}
onClose={close}
onIndexChange={change}
onDelete={removePhoto}
/>
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
</div>
</div>
+149 -3
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { photosApi } from '$lib/api';
import { portal } from '$lib/actions/portal';
import { photosApi, ApiError } from '$lib/api';
import { albums } from '$lib/stores/albums.svelte';
import type { Photo } from '$lib/types';
let photos = $state<Photo[]>([]);
@@ -10,6 +12,13 @@
let pending = $state<Photo | null>(null);
let busy = $state(false);
let restoring = $state<Photo | null>(null);
let targetId = $state('');
let createNew = $state(false);
let newTitle = $state('');
let restoreBusy = $state(false);
let restoreError = $state('');
async function load() {
loading = true;
error = '';
@@ -26,12 +35,47 @@
load();
});
$effect(() => {
if (restoring && !albums.loaded) albums.load();
});
async function restore(p: Photo) {
try {
await photosApi.restore(p.id);
photos = photos.filter((x) => x.id !== p.id);
} catch (e) {
error = (e as Error).message;
if (e instanceof ApiError && e.code === 'album_required') {
restoring = p;
targetId = '';
createNew = false;
newTitle = '';
restoreError = '';
} else {
error = (e as Error).message;
}
}
}
async function confirmRestore() {
const photo = restoring;
if (!photo) return;
restoreBusy = true;
restoreError = '';
try {
let id = targetId;
if (createNew) {
if (!newTitle.trim()) return;
const album = await albums.create(newTitle.trim());
id = album.id;
}
if (!id) return;
await photosApi.restore(photo.id, id);
photos = photos.filter((x) => x.id !== photo.id);
restoring = null;
} catch (e) {
restoreError = (e as Error).message;
} finally {
restoreBusy = false;
}
}
@@ -82,7 +126,9 @@
<p class="text-text truncate text-xs" title={photo.original_filename}>
{photo.original_filename}
</p>
<p class="text-text-subtle truncate text-xs">{photo.album_title ?? ''}</p>
<p class="text-text-subtle truncate text-xs">
{photo.album_title || 'Без альбома'}
</p>
<div class="flex gap-1">
<button
type="button"
@@ -119,3 +165,103 @@
onClose={() => (pending = null)}
/>
{/if}
{#if restoring}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Восстановление фото"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !restoreBusy) restoring = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !restoreBusy) restoring = null;
}}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-5 py-3">
<h2 class="text-text text-base font-semibold">Восстановить фото</h2>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
aria-label="Закрыть"
onclick={() => (restoring = null)}
>
<Icon name="close" class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
<p class="text-text-muted text-xs">
Исходный альбом удалён. Выберите альбом или создайте новый.
</p>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" class="accent-primary size-4" bind:checked={createNew} />
Создать новый альбом
</label>
{#if createNew}
<input
bind:value={newTitle}
placeholder="Название альбома"
maxlength="120"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2 text-sm"
/>
{:else if albums.list.length === 0}
<p class="text-text-subtle text-sm">Нет альбомов — создайте новый.</p>
{:else}
<ul class="max-h-56 space-y-1 overflow-y-auto">
{#each albums.list as album (album.id)}
<li>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
album.id
? 'border-primary bg-bg-muted text-text'
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
aria-pressed={targetId === album.id}
onclick={() => (targetId = album.id)}
>
<span
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
>
{#if album.cover_thumb_url}
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
{:else}
<Icon name="image" class="size-4 text-text-subtle" />
{/if}
</span>
<span class="truncate">{album.title}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if restoreError}<p class="text-danger text-sm">{restoreError}</p>{/if}
</div>
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => (restoring = null)}
>
Отмена
</button>
<button
type="button"
disabled={restoreBusy || (createNew ? !newTitle.trim() : !targetId)}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={confirmRestore}
>
{restoreBusy ? 'Восстановление…' : 'Восстановить'}
</button>
</div>
</div>
</div>
{/if}
+2
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import '../app.css';
import IconSprite from '$lib/components/IconSprite.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme.svelte';
import { density } from '$lib/stores/density.svelte';
@@ -23,3 +24,4 @@
<IconSprite />
{@render children()}
<ContextMenu />
@@ -205,9 +205,60 @@ owner/admin) и загрузка в цель (member+).
### 24. Компактный верхний хром
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок,
иконки `size-4`, компактный переключатель темы. Функциональность (меню, панель, темы, полный
иконки `size-4`, компактный переключатель темы. Верхняя панель просмотра — прозрачный оверлей
(`absolute`, без фона и рамки, `pointer-events-none` с активными кнопками), поэтому не занимает
место в раскладке и увеличивает область фото. Функциональность (меню, панель, темы, полный
экран, навигация, скачивание, удаление) сохраняется.
### 25. Действия в режиме просмотра — в правой панели
Скачивание и удаление убраны из верхней панели просмотра и перенесены в правую панель, в раздел
«Действия», вместе с «Переместить» и «Копировать». Скачивание — ссылка на presigned оригинал;
перенос/копирование — через `transfer`/`TransferDialog`; удаление инициируется стором
`viewerActions`, а обрабатывает его страница просмотра (та же логика перехода к соседнему фото
после удаления).
### 26. Единая панель фото
Содержимое правой панели для одного фото (имя файла, «Действия», «Информация», «Комментарии»)
вынесено в общий компонент `PhotoPanel`, который переиспользуется и в режиме просмотра
(`viewer=true`), и при одиночном выборе фото в сетке — это одна и та же панель, а не копия.
Отличие только в наборе действий: в просмотре нет «Открыть», а удаление идёт через стор
`viewerActions` (обрабатывает страница просмотра с переходом к соседнему фото); при выборе в
сетке удаление выполняет сам компонент и очищает выбор. Мультивыбор (>1) показывает только
раздел «Действия».
### 27. Контекстное меню фото
Правый клик по фото в сетке и по изображению в просмотре открывает контекстное меню с теми же
пунктами, что и раздел «Действия». Меню — общий стор `contextMenu` (`x`, `y`, items) и глобальный
компонент `ContextMenu` (портал), закрывается по клику вне, `Escape`, скроллу и ресайзу.
Пункты формируются на местах: в сетке — «Открыть (просмотр)», «Скачать», «Переместить»,
«Копировать», «Удалить» (для мультивыбора без «Скачать»); в просмотре — без «Открыть».
### 28. Удаление альбома и восстановление фото
Удаление альбома не должно терять файлы: перед удалением альбома его фото мягко удаляются
(`deleted_at`), а FK `photos.album_id` меняется на `ON DELETE SET NULL` (колонка nullable,
миграция `000006`) — после удаления альбома фото остаются строками с `album_id = NULL` в
корзине. Объекты S3 не удаляются (каскадный `DeletePrefix` убран: он ломал бы копии в других
альбомах). `GET /trash` включает такие «без альбома» фото загрузчика. Восстановление с
указанием целевого альбома (`POST /photos/:id/restore {album_id}`) переносит фото туда; без
цели и без альбома возвращается ошибка `album_required`, и клиент предлагает выбрать альбом или
создать новый.
### 29. Контекстное меню альбома и счётчики в рейле
Правый клик по альбому (в рейле и на обзоре) открывает меню «Переименовать»/«Удалить» через
общий стор `contextMenu`; диалоги переименования и подтверждения удаления — глобальный
`AlbumActionsDialogs` (стор `albumActions`). В рейле у каждого альбома показано число фото
(`album.photos_count`), у корзины — число удалённых фото (лёгкий `GET /trash/count`, стор
`trash`).
### 30. Метки фото (область — пользователь)
Метки принадлежат пользователю: таблицы `tags` (`user_id`, уникальность по `lower(name)`) и
`photo_tags` (миграция `000007`). Фото отдаёт метки текущего пользователя (подзапрос
`json_agg` в `photoColumns` по `$2`-viewer). Эндпоинты: `GET /tags?q=` (поиск по меткам
пользователя), `POST /photos/:id/tags {name}` (создаёт метку при необходимости и привязывает,
возвращает обновлённое фото), `DELETE /photos/:id/tags/:tag_id`. UI — раздел «Метки» в
`PhotoPanel`: чипы с удалением и поле с автодополнением (поиск + «Создать «…»»). Права: метку
можно ставить на фото альбома, доступного пользователю.
## Risks / Trade-offs
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
@@ -35,10 +35,19 @@
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
фото есть команда бэкфилла метаданных из оригиналов.
- **Метки фото.** Пользователь присваивает фото свои метки: создаёт новые и выбирает из списка
с поиском; метки показываются и снимаются в разделе «Метки» панели фото.
- **Контекстные меню.** Правый клик по фото (в сетке и в просмотре) открывает меню с пунктами
раздела «Действия»; правый клик по альбому (в рейле и на обзоре) — меню «Переименовать»/
«Удалить».
- **Удаление альбома.** Удаление альбома помещает его фото в корзину, не удаляя объекты
хранилища; при восстановлении фото без альбома предлагается выбрать существующий альбом или
создать новый.
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
удаление текущего фото с модальным подтверждением; возврат к обзору.
действия просмотра (скачать, удалить, перенести, копировать) — в правой панели, в разделе
«Действия»; удаление с модальным подтверждением; возврат к обзору.
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
@@ -55,7 +64,8 @@
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
обзоре убрана пустая правая панель.
- **Счётчики.** В ответе альбома — число фото и участников.
- **Счётчики.** В ответе альбома — число фото и участников; в рейле у каждого альбома показано
число фото, у корзины — число удалённых фото.
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
@@ -72,9 +82,10 @@
### Modified Capabilities
- `albums`: отображение обложки, счётчики фото/участников и явный выбор обложки.
- `albums`: отображение обложки, счётчики фото/участников, явный выбор обложки и удаление в
корзину.
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование и корзина.
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование, корзина и метки.
- `auth`: хранение пользовательских настроек отображения информации в профиле.
## Impact
@@ -89,7 +100,11 @@
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
`models.User.Preferences`, `internal/auth` (`Me` отдаёт настройки,
`PATCH /auth/me/preferences` с валидацией).
`PATCH /auth/me/preferences` с валидацией); миграции `000005_photo_trash` (мягкое удаление),
`000006_photo_album_nullable` (FK `photos.album_id` → `ON DELETE SET NULL`, удаление альбома
в корзину), `000007_photo_tags` (метки); `GET /trash`/`GET /trash/count`,
`POST /photos/:id/restore` (c `album_id`), `DELETE /photos/:id/permanent`,
`POST /photo-batch/{delete,move,copy}`, `GET /tags`, `POST/DELETE /photos/:id/tags`.
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
@@ -97,7 +112,9 @@
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
`photos_count`, `preferences`); новые иконки в реестре.
`photos_count`, `preferences`, `tags`); общие компоненты `PhotoPanel`, `ContextMenu`,
`TransferDialog`, `AlbumActionsDialogs`; маршрут `/trash`; сторы `selection`, `transfer`,
`contextMenu`, `albumActions`, `trash`, `viewerActions`; новые иконки в реестре.
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
затрагивается — иконки по-прежнему наследуют цвет темы.
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
@@ -87,3 +87,32 @@
#### Scenario: Окончательное удаление при наличии ссылок
- **WHEN** на объект хранилища ссылается копия в другом альбоме
- **THEN** запись удаляется, а объект хранилища сохраняется
### Requirement: Удаление альбома и восстановление фото без альбома
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
альбом или создать новый.
#### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
#### Scenario: Восстановление без альбома
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
### Requirement: Метки фото
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
#### Scenario: Создание метки
- **WHEN** пользователь вводит новую метку и подтверждает
- **THEN** метка создаётся и привязывается к фото
#### Scenario: Выбор из списка с поиском
- **WHEN** пользователь вводит часть названия метки
- **THEN** показываются подходящие метки пользователя для выбора
#### Scenario: Снятие метки
- **WHEN** пользователь убирает метку с фото
- **THEN** метка отвязывается от фото
@@ -316,10 +316,69 @@ Ctrl/Cmd-клик — добавить или убрать, Shift-клик —
- **WHEN** пользователь открывает обзор альбомов
- **THEN** правая панель не отображается
### Requirement: Действия в режиме просмотра
В режиме просмотра правая панель SHALL содержать раздел «Действия» со строками-пунктами
(иконка + подпись) «Скачать», «Переместить», «Копировать» и «Удалить» (удаление — по правам).
Верхняя панель просмотра SHALL не дублировать скачивание и удаление.
#### Scenario: Действия в просмотре
- **WHEN** открыт режим просмотра фото
- **THEN** раздел «Действия» содержит «Скачать», «Переместить», «Копировать» и «Удалить», а в верхней панели этих кнопок нет
#### Scenario: Удаление из панели
- **WHEN** пользователь удаляет фото из раздела «Действия»
- **THEN** открывается подтверждение, и после удаления просмотр переходит к соседнему фото
### Requirement: Контекстное меню фото
Правый клик по фото в сетке и по изображению в режиме просмотра SHALL открывать контекстное
меню с пунктами раздела «Действия» («Скачать», «Переместить», «Копировать», «Удалить»; в сетке
также «Открыть (просмотр)»), выполняющими те же действия.
#### Scenario: Меню в сетке
- **WHEN** пользователь кликает правой кнопкой по фото в сетке
- **THEN** открывается меню с пунктами «Открыть (просмотр)», «Скачать», «Переместить», «Копировать», «Удалить»
#### Scenario: Меню в просмотре
- **WHEN** пользователь кликает правой кнопкой по изображению в режиме просмотра
- **THEN** открывается меню с пунктами «Скачать», «Переместить», «Копировать», «Удалить»
### Requirement: Контекстное меню альбома
Правый клик по альбому в рейле и по карточке на обзоре SHALL открывать меню с пунктами
«Переименовать» и «Удалить»; переименование SHALL открывать диалог, удаление — подтверждение.
#### Scenario: Меню альбома
- **WHEN** пользователь кликает правой кнопкой по альбому
- **THEN** открывается меню с «Переименовать» и «Удалить»
#### Scenario: Удаление альбома
- **WHEN** пользователь подтверждает удаление альбома
- **THEN** альбом удаляется, а его фото попадают в корзину
### Requirement: Счётчики в рейле
Рейл SHALL показывать число фото у каждого альбома и число элементов в корзине.
#### Scenario: Счётчики
- **WHEN** пользователь смотрит на рейл альбомов
- **THEN** у каждого альбома показано число фото, а у корзины — число удалённых фото
### Requirement: Метки в панели фото
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
поиск по меткам пользователя и создание новых.
#### Scenario: Раздел «Метки»
- **WHEN** открыт просмотр или выбран один фото в сетке
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
### Requirement: Компактный верхний хром
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
(узкие отступы, иконочные органы управления), сохраняя все действия доступными.
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
увеличивая область фото.
#### Scenario: Компактные верхние панели
- **WHEN** открыт список альбомов или режим просмотра фото
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
#### Scenario: Прозрачная панель просмотра
- **WHEN** открыт режим просмотра фото
- **THEN** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения
@@ -76,3 +76,10 @@
- [x] 9.15 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
- [x] 9.16 Раздел «Действия» в панели — пункты-надписи с иконками (не кнопки): «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; надпись «Выбрано:» убрана. Проверка: Playwright — «Открыть (просмотр)» открывает просмотр, раздел присутствует (4/4).
- [x] 9.17 Компактный верхний хром: верхняя панель приложения и верхняя панель режима просмотра сведены к минимуму (узкие отступы, иконочные кнопки `size-4`, компактный переключатель темы). Проверка: высота `<header>` ~31px, верхней панели просмотра ~29px; `npm run check`.
- [x] 9.18 Действия просмотра перенесены в правую панель: в разделе «Действия» — «Скачать», «Переместить», «Копировать», «Удалить»; из верхней панели просмотра скачивание и удаление убраны; удаление через стор `viewerActions` и страницу просмотра. Проверка: Playwright — верх без скачать/удалить, панель с действиями, удаление из панели переходит к соседнему фото (9/9).
- [x] 9.19 Единая панель фото: содержимое для одного фото вынесено в общий компонент `PhotoPanel`, переиспользуемый в просмотре и при одиночном выборе в сетке (не копия); мультивыбор — только «Действия». Проверка: Playwright — просмотр 9/9, выбор 5/5, мультивыбор 9/9; `npm run check`.
- [x] 9.20 Контекстное меню фото (правая кнопка) в сетке и просмотре с пунктами из «Действий»; общий стор `contextMenu` и глобальный `ContextMenu`. Проверка: Playwright — меню в сетке (5 пунктов), меню в просмотре (4 пункта), «Открыть» из меню и «Переместить» → диалог (12/12).
- [x] 9.21 Контекстное меню альбома («Переименовать»/«Удалить») в рейле и на обзоре; диалоги в `AlbumActionsDialogs`. Проверка: Playwright — меню, переименование, удаление (4/4).
- [x] 9.22 Удаление альбома помещает фото в корзину без потери файлов (FK `photos.album_id` → `ON DELETE SET NULL`, миграция `000006`); восстановление без альбома предлагает выбрать/создать альбом (`album_required` + `POST /photos/:id/restore {album_id}`). Проверка: Playwright — удалить альбом → фото в корзине, диалог восстановления, фото в новом альбоме (7/7).
- [x] 9.23 Счётчики в рейле: число фото у альбома и число элементов корзины (`GET /trash/count`, стор `trash`); обновление счётчика альбома после загрузки/удаления. Проверка: Playwright — альбом 2, корзина 0→1 (3/3).
- [x] 9.24 Метки фото (область — пользователь): таблицы `tags`/`photo_tags` (миграция `000007`), `GET /tags?q=`, `POST /photos/:id/tags`, `DELETE /photos/:id/tags/:tag_id`, метки в ответе фото; раздел «Метки» в панели с чипами, поиском и созданием. Проверка: API — создание/поиск/снятие; Playwright — создание, снятие, выбор из поиска (4/4).