From 2d6a3fea1a38eaece36cb26b5c3c8042a3ba3ca8 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 22:13:17 +0300 Subject: [PATCH] feat: photo multiselect, move/copy, trash and compact chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: soft delete + trash (photos.deleted_at, migration 000005), GET /trash, restore, permanent delete with storage-key refcount (S3 objects removed only when no other album references them); batch endpoints photo-batch/{delete,move,copy} - metadata only, copies clone rows sharing storage keys - frontend: Ctrl/Shift multiselect; right-panel 'Действия' as icon text rows (Открыть (просмотр), Переместить, Копировать, Удалить); drag selected photos onto rail album -> TransferDialog; /trash route; trash entry as last item in the left rail; empty right panel hidden on overview - frontend: compact top bars (app header and viewer), compact theme switcher - openspec: sync lightroom-workspace-ui artifacts (photos/ui-workspace specs, design #20-24, tasks 9.11-9.17) --- backend/internal/app/app.go | 7 + backend/internal/models/models.go | 2 + backend/internal/photos/handlers.go | 73 +++++++ backend/internal/photos/service.go | 197 +++++++++++++++++- backend/internal/photos/store.go | 108 +++++++++- .../migrations/000005_photo_trash.down.sql | 2 + backend/migrations/000005_photo_trash.up.sql | 2 + frontend/src/lib/api.ts | 20 +- frontend/src/lib/components/AlbumPanel.svelte | 82 +++++++- frontend/src/lib/components/AlbumRail.svelte | 55 ++++- frontend/src/lib/components/AppShell.svelte | 41 ++-- frontend/src/lib/components/PhotoGrid.svelte | 22 +- .../src/lib/components/ThemeSwitcher.svelte | 23 +- .../src/lib/components/TransferDialog.svelte | 157 ++++++++++++++ frontend/src/lib/components/Viewer.svelte | 19 +- frontend/src/lib/stores/selection.svelte.ts | 26 ++- frontend/src/lib/stores/transfer.svelte.ts | 25 +++ frontend/src/lib/types.ts | 2 + frontend/src/routes/(app)/+layout.svelte | 8 +- frontend/src/routes/(app)/+page.svelte | 11 +- .../src/routes/(app)/albums/[id]/+page.svelte | 48 ++++- frontend/src/routes/(app)/trash/+page.svelte | 121 +++++++++++ .../changes/lightroom-workspace-ui/design.md | 34 +++ .../lightroom-workspace-ui/proposal.md | 10 +- .../specs/photos/spec.md | 34 +++ .../specs/ui-workspace/spec.md | 46 ++++ .../changes/lightroom-workspace-ui/tasks.md | 7 + 27 files changed, 1097 insertions(+), 85 deletions(-) create mode 100644 backend/migrations/000005_photo_trash.down.sql create mode 100644 backend/migrations/000005_photo_trash.up.sql create mode 100644 frontend/src/lib/components/TransferDialog.svelte create mode 100644 frontend/src/lib/stores/transfer.svelte.ts create mode 100644 frontend/src/routes/(app)/trash/+page.svelte diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 5bfc986..30ba552 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -137,6 +137,13 @@ func (s *Server) Router() *gin.Engine { authed.PATCH("/photos/:id", photosHandler.Rename) authed.DELETE("/photos/:id", photosHandler.Delete) authed.GET("/photos/:id/download", photosHandler.Download) + authed.POST("/photos/:id/restore", photosHandler.Restore) + authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete) + + 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.POST("/photos/:id/likes", socialHandler.AddLike) authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 0ff50b2..d4df5de 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -78,6 +78,8 @@ type Photo struct { Longitude *float64 `json:"longitude,omitempty"` Caption string `json:"caption"` EXIF map[string]any `json:"exif,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + AlbumTitle string `json:"album_title,omitempty"` CreatedAt time.Time `json:"created_at"` Uploader PublicUser `json:"uploader"` LikesCount int `json:"likes_count"` diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 5c90772..61dd588 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -133,3 +133,76 @@ func (h *Handler) Delete(c *gin.Context) { } c.Status(http.StatusNoContent) } + +func (h *Handler) PermanentDelete(c *gin.Context) { + if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil { + httpx.RespondError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *Handler) Restore(c *gin.Context) { + p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photo": p}) +} + +func (h *Handler) Trash(c *gin.Context) { + list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photos": list}) +} + +type batchRequest struct { + IDs []string `json:"ids" binding:"required"` + AlbumID string `json:"album_id"` +} + +func (h *Handler) DeleteBatch(c *gin.Context) { + var req batchRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required")) + return + } + n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) +} + +func (h *Handler) MoveBatch(c *gin.Context) { + var req batchRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required")) + return + } + n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"moved": n}) +} + +func (h *Handler) CopyBatch(c *gin.Context) { + var req batchRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required")) + return + } + created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photos": created}) +} diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index e2c4184..1465c76 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -207,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, } return nil, err } + if p.DeletedAt != nil { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } if err := s.attachURLs(ctx, p); err != nil { return nil, err } @@ -230,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot } return nil, err } + if p.DeletedAt != nil { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil { return nil, err } @@ -297,23 +303,192 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error { if err != nil { return err } - own := p.UploaderID == userID - if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) { + if !canDeletePhoto(role, p.UploaderID == userID) { return httpx.Forbidden("forbidden", "you cannot delete this photo") } - if err := s.store.Delete(ctx, photoID); err != nil { + if p.DeletedAt != nil { + return nil + } + if err := s.store.SetDeleted(ctx, photoID, true); err != nil { return err } - keys := []string{p.StorageKey} - if p.ThumbKey != "" { - keys = append(keys, p.ThumbKey) - } - if p.PreviewKey != "" { - keys = append(keys, p.PreviewKey) - } - _ = s.storage.Delete(ctx, keys...) + s.publishDeleted(p) + return nil +} + +func canDeletePhoto(role string, own bool) bool { + return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role)) +} + +func (s *Service) publishDeleted(p *models.Photo) { s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{ "id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID, }) +} + +// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete. +func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) { + deleted := 0 + for _, id := range ids { + if err := s.Delete(ctx, id, userID); err != nil { + continue + } + deleted++ + } + return deleted, nil +} + +// Restore returns a photo from the trash back to its album. +func (s *Service) Restore(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 + } + _, 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 { + 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) + return p, nil +} + +// PermanentDelete removes the photo row; stored objects are deleted only when no +// other album references the same storage key. +func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error { + p, err := s.store.GetByID(ctx, photoID, userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return httpx.NotFound("photo_not_found", "photo not found") + } + return err + } + _, role, err := s.albums.Access(ctx, p.AlbumID, userID) + if err != nil { + return err + } + if !canDeletePhoto(role, p.UploaderID == userID) { + return httpx.Forbidden("forbidden", "you cannot delete this photo") + } + if err := s.store.HardDelete(ctx, photoID); err != nil { + return err + } + if p.AlbumID != "" { + s.publishDeleted(p) + } + if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 { + keys := []string{p.StorageKey} + if p.ThumbKey != "" { + keys = append(keys, p.ThumbKey) + } + if p.PreviewKey != "" { + keys = append(keys, p.PreviewKey) + } + _ = s.storage.Delete(ctx, keys...) + } return nil } + +// MoveBatch moves photos to another album (metadata only; stored objects untouched). +func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) { + _, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID) + if err != nil { + return 0, err + } + if !roles.CanUpload(targetRole) { + return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album") + } + moved := 0 + for _, id := range ids { + p, err := s.store.GetByID(ctx, id, userID) + if err != nil || p.DeletedAt != nil { + continue + } + oldAlbum := p.AlbumID + if oldAlbum == targetAlbumID { + continue + } + _, srcRole, err := s.albums.Access(ctx, oldAlbum, userID) + if err != nil { + continue + } + if !canDeletePhoto(srcRole, p.UploaderID == userID) { + continue + } + if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil { + continue + } + p.AlbumID = targetAlbumID + s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{ + "id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID, + }) + if err := s.attachURLs(ctx, p); err == nil { + s.hub.Publish(targetAlbumID, "photo.created", p) + } + moved++ + } + return moved, nil +} + +// CopyBatch clones photos into another album (metadata only; stored objects shared). +func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) { + _, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID) + if err != nil { + return nil, err + } + if !roles.CanUpload(targetRole) { + return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album") + } + out := []models.Photo{} + for _, id := range ids { + src, err := s.store.GetByID(ctx, id, userID) + if err != nil || src.DeletedAt != nil { + continue + } + if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil { + continue + } + newID := auth.NewID() + if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil { + continue + } + clone, err := s.store.GetByID(ctx, newID, userID) + if err != nil { + continue + } + if err := s.attachURLs(ctx, clone); err != nil { + continue + } + s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID) + s.hub.Publish(targetAlbumID, "photo.created", clone) + out = append(out, *clone) + } + return out, nil +} + +// Trash lists soft-deleted photos from all albums the user belongs to. +func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) { + out, err := s.store.ListTrash(ctx, userID) + if err != nil { + return nil, err + } + for i := range out { + _ = s.attachURLs(ctx, &out[i]) + } + return out, nil +} diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index bc4dec8..2bcf76b 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "shared-album/internal/models" @@ -28,7 +29,7 @@ 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` + COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at` func scanPhoto(row pgx.Row) (*models.Photo, error) { var p models.Photo @@ -38,7 +39,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) { &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.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -103,6 +104,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) `SELECT `+photoColumns+` FROM photos p JOIN users u ON u.id = p.uploader_id WHERE p.album_id = $1 + AND p.deleted_at IS NULL AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3)) AND ($4::timestamptz IS NULL OR p.created_at > $4) ORDER BY p.created_at DESC, p.id DESC @@ -122,7 +124,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) &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); err != nil { + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt); err != nil { return nil, err } setExif(&p, exifText) @@ -131,7 +133,62 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) return out, rows.Err() } -func (s *Store) Delete(ctx context.Context, id string) error { +func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error { + var tag pgconn.CommandTag + var err error + if deleted { + tag, err = s.pool.Exec(ctx, + `UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id) + } else { + tag, err = s.pool.Exec(ctx, + `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id) + } + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error { + tag, err := s.pool.Exec(ctx, + `UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Clone creates a new photo row that references the same stored objects as src. +func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error { + exifJSON, err := json.Marshal(src.EXIF) + if err != nil || len(exifJSON) == 0 { + exifJSON = []byte("{}") + } + _, err = s.pool.Exec(ctx, + `INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key, + original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`, + id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey), + src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType, + src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)) + return err +} + +// StorageKeyRefCount counts photo rows (any state) referencing the stored object. +func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n) + return n, err +} + +// HardDelete permanently removes the photo row. +func (s *Store) HardDelete(ctx context.Context, id string) error { tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id) if err != nil { return err @@ -142,6 +199,49 @@ func (s *Store) Delete(ctx context.Context, id string) error { return nil } +// ListTrash returns soft-deleted photos from albums the user belongs to. +func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) { + rows, err := s.pool.Query(ctx, + `SELECT `+photoColumns+`, a.title + FROM photos p + JOIN users u ON u.id = p.uploader_id + 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)) + ORDER BY p.deleted_at DESC + LIMIT 200`, + userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.Photo{} + for rows.Next() { + var p models.Photo + var exifText 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 { + return nil, err + } + setExif(&p, exifText) + out = append(out, p) + } + return out, rows.Err() +} + +func nullIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) { _, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, diff --git a/backend/migrations/000005_photo_trash.down.sql b/backend/migrations/000005_photo_trash.down.sql new file mode 100644 index 0000000..efa7285 --- /dev/null +++ b/backend/migrations/000005_photo_trash.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS photos_deleted_idx; +ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at; diff --git a/backend/migrations/000005_photo_trash.up.sql b/backend/migrations/000005_photo_trash.up.sql new file mode 100644 index 0000000..0e2a655 --- /dev/null +++ b/backend/migrations/000005_photo_trash.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE photos ADD COLUMN IF NOT EXISTS deleted_at timestamptz; +CREATE INDEX IF NOT EXISTS photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7d45bc8..1244686 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,6 @@ import { goto } from '$app/navigation'; import { auth, type Tokens, type User } from './stores/auth.svelte'; -import type { Preferences } from './types'; +import type { Photo, Preferences } from './types'; export class ApiError extends Error { status: number; @@ -105,3 +105,21 @@ export const authApi = { body: preferences }) }; + +export const photosApi = { + deleteBatch: (ids: string[]) => + api<{ deleted: number }>('/photo-batch/delete', { method: 'POST', body: { ids } }), + moveBatch: (ids: string[], albumId: string) => + api<{ moved: number }>('/photo-batch/move', { + method: 'POST', + body: { ids, album_id: albumId } + }), + copyBatch: (ids: string[], albumId: string) => + api<{ photos: Photo[] }>('/photo-batch/copy', { + method: 'POST', + 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' }) +}; diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index 6a705f5..5d549c9 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -1,14 +1,17 @@ +{#snippet selectionActions()} +
+

Действия

+ +
+{/snippet} + {#if panelPhoto} {@const p = panelPhoto} + {#if !photoId}{@render selectionActions()}{/if}
{#if editingName} @@ -293,6 +354,8 @@ onClose={() => (settingsOpen = false)} /> {/if} +{:else if !photoId && selectedItems.length > 1} + {@render selectionActions()} {:else if album && !photoId}
@@ -365,3 +428,14 @@ /> {/if} {/if} + +{#if pendingDeleteSelected} + (pendingDeleteSelected = false)} + /> +{/if} diff --git a/frontend/src/lib/components/AlbumRail.svelte b/frontend/src/lib/components/AlbumRail.svelte index 168ff32..59f839e 100644 --- a/frontend/src/lib/components/AlbumRail.svelte +++ b/frontend/src/lib/components/AlbumRail.svelte @@ -5,9 +5,36 @@ import CreateAlbumDialog from './CreateAlbumDialog.svelte'; import { albums } from '$lib/stores/albums.svelte'; import { auth } from '$lib/stores/auth.svelte'; + import { transfer } from '$lib/stores/transfer.svelte'; const activeId = $derived($page.params.id ?? ''); + const onTrash = $derived($page.url.pathname === '/trash'); let creating = $state(false); + let dropId = $state(null); + + function onDragOver(ev: DragEvent, albumId: string) { + if (!ev.dataTransfer?.types.includes('application/x-galo-photos')) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + dropId = albumId; + } + + function onDragLeave(albumId: string) { + if (dropId === albumId) dropId = null; + } + + function onDrop(ev: DragEvent, albumId: string) { + dropId = null; + const raw = ev.dataTransfer?.getData('application/x-galo-photos'); + if (!raw) return; + ev.preventDefault(); + try { + const ids = JSON.parse(raw) as string[]; + transfer.start(ids, albumId); + } catch { + /* ignore */ + } + } $effect(() => { if (auth.isAuthenticated && !albums.loaded) albums.load(); @@ -42,11 +69,16 @@
  • onDragOver(e, album.id)} + ondragleave={() => onDragLeave(album.id)} + ondrop={(e) => onDrop(e, album.id)} > {/if} + {#if auth.isAuthenticated} + + {/if} + {#if creating} (creating = false)} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 9e27984..1938748 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -11,9 +11,10 @@ children: Snippet; rail?: Snippet; panel?: Snippet; + panelVisible?: boolean; } - let { children, rail, panel }: Props = $props(); + let { children, rail, panel, panelVisible = true }: Props = $props(); let railOpen = $state(false); let panelOpen = $state(false); @@ -101,40 +102,40 @@
    -
    +
    - - + + Galo
    -
    - {#if panel} +
    + {#if panel && panelVisible} + onclick={() => (panelOpen = true)} + > + + {/if} {#if auth.isAuthenticated} - +
    @@ -195,7 +196,7 @@ {@render children()} - {#if panel} + {#if panel && panelVisible} {#if panelOpen} {/each}
    diff --git a/frontend/src/lib/components/TransferDialog.svelte b/frontend/src/lib/components/TransferDialog.svelte new file mode 100644 index 0000000..f215b6f --- /dev/null +++ b/frontend/src/lib/components/TransferDialog.svelte @@ -0,0 +1,157 @@ + + + + +{#if transfer.open} + +{/if} diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index d5dac1a..4861d07 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -99,8 +99,8 @@ const topClass = $derived( isFullscreen - ? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-2 border-b px-3 py-2 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}` - : 'border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2' + ? `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( isFullscreen @@ -289,22 +289,21 @@ } const btn = - 'border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm'; - const dangerBtn = - 'border-border text-danger hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm'; + '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';
    -
    +
    - {index + 1} / {photos.length} + {index + 1} / {photos.length}
    -
    +
    {#if fresh?.download_url} (null); + items = $state([]); + anchorId = $state(null); - select(albumId: string, photo: Photo) { + get ids(): string[] { + return this.items.map((p) => p.id); + } + + get count(): number { + return this.items.length; + } + + has(id: string): boolean { + return this.items.some((p) => p.id === id); + } + + set(albumId: string, items: Photo[]) { this.albumId = albumId; - this.photo = photo; + this.items = items; + } + + replace(photo: Photo) { + this.items = this.items.map((p) => (p.id === photo.id ? photo : p)); } clear() { this.albumId = ''; - this.photo = null; + this.items = []; + this.anchorId = null; } } diff --git a/frontend/src/lib/stores/transfer.svelte.ts b/frontend/src/lib/stores/transfer.svelte.ts new file mode 100644 index 0000000..c7e37ad --- /dev/null +++ b/frontend/src/lib/stores/transfer.svelte.ts @@ -0,0 +1,25 @@ +export type TransferAction = 'move' | 'copy'; + +class TransferStore { + open = $state(false); + ids = $state([]); + targetId = $state(null); + action = $state(null); + + start(ids: string[], targetId: string | null = null, action: TransferAction | null = null) { + if (ids.length === 0) return; + this.ids = ids; + this.targetId = targetId; + this.action = action; + this.open = true; + } + + close() { + this.open = false; + this.ids = []; + this.targetId = null; + this.action = null; + } +} + +export const transfer = new TransferStore(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7b1b5ab..1826313 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -50,6 +50,8 @@ export interface Photo { longitude?: number | null; caption: string; exif?: Record; + deleted_at?: string | null; + album_title?: string; created_at: string; uploader: User; likes_count: number; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 10f5422..d7d5f34 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -1,12 +1,16 @@ - + {#snippet rail()} {/snippet} @@ -15,3 +19,5 @@ {/snippet} {@render children()} + + diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte index 3323e8a..e0ec549 100644 --- a/frontend/src/routes/(app)/+page.svelte +++ b/frontend/src/routes/(app)/+page.svelte @@ -31,12 +31,13 @@ {#if albums.loading && albums.list.length === 0}

    Загрузка…

    - {:else if albums.list.length === 0} -
    - -

    Альбомов пока нет. Создайте первый кнопкой + в панели альбомов.

    -
    {:else} + {#if albums.list.length === 0} +
    + +

    Альбомов пока нет. Создайте первый кнопкой + в панели альбомов.

    +
    + {/if}
      p.id === selection.anchorId); + if (a >= 0) { + const lo = Math.min(a, i); + const hi = Math.max(a, i); + selection.set(albumId, photos.slice(lo, hi + 1)); + return; + } + } + if (e.ctrlKey || e.metaKey) { + const exists = cur.some((p) => p.id === photo.id); + selection.set( + albumId, + exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo] + ); + selection.anchorId = photo.id; + return; + } + selection.set(albumId, [photo]); + selection.anchorId = photo.id; + } + + function dragPhoto(i: number, e: DragEvent) { + const photo = photos[i]; + if (!photo) return; + if (!(selection.albumId === albumId && selection.has(photo.id))) { + selection.set(albumId, [photo]); + selection.anchorId = photo.id; + } + const ids = selection.ids; + e.dataTransfer?.setData('application/x-galo-photos', JSON.stringify(ids)); + e.dataTransfer?.setData('text/plain', `${ids.length} фото`); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copyMove'; + } + function onDrop(ev: DragEvent) { ev.preventDefault(); dragging = false; @@ -151,6 +190,7 @@ aria-label="Альбом" ondragover={(e) => { if (!canUpload) return; + if (!e.dataTransfer?.types.includes('Files')) return; e.preventDefault(); dragging = true; }} @@ -202,11 +242,9 @@ {:else} { - const p = photos[i]; - if (p) selection.select(albumId, p); - }} + selectedIds={selection.albumId === albumId ? selection.ids : []} + onSelect={selectPhoto} + onDragStart={dragPhoto} onOpen={(i) => { const p = photos[i]; if (p) goto(`/albums/${albumId}/photos/${p.id}`); diff --git a/frontend/src/routes/(app)/trash/+page.svelte b/frontend/src/routes/(app)/trash/+page.svelte new file mode 100644 index 0000000..62f5191 --- /dev/null +++ b/frontend/src/routes/(app)/trash/+page.svelte @@ -0,0 +1,121 @@ + + +
      +
      + +

      Корзина

      +
      + + {#if error}

      {error}

      {/if} + + {#if loading} +

      Загрузка…

      + {:else if photos.length === 0} +
      + +

      Корзина пуста.

      +
      + {:else} +
        + {#each photos as photo (photo.id)} +
      • +
        + {#if photo.thumb_url} + + {:else} + + + + {/if} +
        +
        +

        + {photo.original_filename} +

        +

        {photo.album_title ?? ''}

        +
        + + +
        +
        +
      • + {/each} +
      + {/if} +
      + +{#if pending} + (pending = null)} + /> +{/if} diff --git a/openspec/changes/lightroom-workspace-ui/design.md b/openspec/changes/lightroom-workspace-ui/design.md index eeacc11..f4c6e96 100644 --- a/openspec/changes/lightroom-workspace-ui/design.md +++ b/openspec/changes/lightroom-workspace-ui/design.md @@ -174,6 +174,40 @@ EXIF извлекается при загрузке и хранится в `phot маршрута, а в альбоме — у выбора; выбор сбрасывается при смене альбома. Альтернатива — хранить выбор в URL (query) — избыточно для локального выделения. +### 20. Корзина: мягкое удаление и подсчёт ссылок +Удаление фото — мягкое (`photos.deleted_at`, миграция `000005_photo_trash`): списки и просмотр +исключают такие фото, `GET /trash` отдаёт их по альбомам пользователя (владелец или участник). +Восстановление снимает `deleted_at`. Окончательное удаление удаляет запись, и только если +после этого `storage_key` больше нигде не встречается (копии в других альбомах разделяют +объект), удаляются объекты S3 (оригинал, thumb, preview). Альтернатива — удалять S3 сразу — +порвала бы копии. + +### 21. Перенос и копирование — только метаданные +Перенос меняет `photos.album_id`; копирование создаёт новую запись с тем же `storage_key`, +thumb/preview и метаданными (клон). Объекты S3 не дублируются, поэтому «ссылки в любых +альбомах» — это строки photos с общим `storage_key`. Права: удаление в источнике (автор или +owner/admin) и загрузка в цель (member+). + +### 22. Мультивыбор и трансфер через общие сторы +Выбор — стор `selection` (набор фото + anchor); Ctrl/Cmd — toggle, Shift — диапазон по +индексам текущей сетки. Действия инициируют диалог: стор `transfer` хранит ids, необязательный +целевой альбом и действие; глобальный `TransferDialog` (портал) выбирает альбом и +перенос/копию. Перетаскивание с сетки на альбом рейла передаёт ids через `dataTransfer` типа +`application/x-galo-photos`; дроп-цель — элементы рейла. Обработчик загрузки показывает оверлей +только для типа `Files`, поэтому при переносе фото зона загрузки не появляется. + +### 23. Рейл: вход в корзину и скрытие панели +В рейле альбомов последним элементом списка добавлена «Корзина» (иконка корзины), ведущая на +маршрут `/trash` со списком удалённых фото и действиями «Вернуть»/«Удалить навсегда». Пустая +правая панель на обзоре скрыта: `AppShell` получает `panelVisible`, который layout выключает на +корневом маршруте. + +### 24. Компактный верхний хром +Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к +минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок, +иконки `size-4`, компактный переключатель темы. Функциональность (меню, панель, темы, полный +экран, навигация, скачивание, удаление) сохраняется. + ## Risks / Trade-offs - [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети; diff --git a/openspec/changes/lightroom-workspace-ui/proposal.md b/openspec/changes/lightroom-workspace-ui/proposal.md index 1825f74..6b3c520 100644 --- a/openspec/changes/lightroom-workspace-ui/proposal.md +++ b/openspec/changes/lightroom-workspace-ui/proposal.md @@ -48,10 +48,18 @@ (клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет настройки из профиля. +- **Мультивыбор и операции.** Ctrl/Shift-выбор нескольких фото в сетке; в правой панели — + раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; + перетаскивание выделенных фото на альбом в рейле открывает диалог перенос/копирование. + Перенос и копирование меняют только метаданные (объекты S3 не трогаются). Любое удаление + помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других + альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на + обзоре убрана пустая правая панель. - **Счётчики.** В ответе альбома — число фото и участников. - **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт, `neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая → системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы. + Верхняя панель приложения и верхняя панель режима просмотра — компактные (минимум высоты). - **Адаптив.** На узких экранах рейл становится off-canvas drawer, правая панель — bottom-sheet, filmstrip — горизонтальным скроллом. @@ -66,7 +74,7 @@ - `albums`: отображение обложки, счётчики фото/участников и явный выбор обложки. - `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера - загрузки; извлечение и хранение EXIF-метаданных. + загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование и корзина. - `auth`: хранение пользовательских настроек отображения информации в профиле. ## Impact diff --git a/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md b/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md index 0f84edc..0336c28 100644 --- a/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md +++ b/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md @@ -53,3 +53,37 @@ #### Scenario: Фото без метаданных - **WHEN** загружается фото без читаемых EXIF - **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно + +### Requirement: Перенос и копирование фото между альбомами +Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь +возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только +метаданные и SHALL не изменять объекты хранилища. + +#### Scenario: Перенос +- **WHEN** пользователь переносит фото в другой альбом +- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются + +#### Scenario: Копирование +- **WHEN** пользователь копирует фото в другой альбом +- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3 + +### Requirement: Корзина фото +Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать +фото в его альбом. Окончательное удаление SHALL удалять запись, а объекты хранилища SHALL +удаляться только когда на них не остаётся ссылок ни в одном альбоме. + +#### Scenario: Удаление в корзину +- **WHEN** пользователь удаляет фото +- **THEN** фото пропадает из альбома и появляется в корзине + +#### Scenario: Восстановление +- **WHEN** пользователь восстанавливает фото из корзины +- **THEN** фото возвращается в свой альбом + +#### Scenario: Окончательное удаление без ссылок +- **WHEN** пользователь удаляет фото из корзины навсегда, и на те же объекты нет ссылок в других альбомах +- **THEN** запись и объекты хранилища удаляются + +#### Scenario: Окончательное удаление при наличии ссылок +- **WHEN** на объект хранилища ссылается копия в другом альбоме +- **THEN** запись удаляется, а объект хранилища сохраняется diff --git a/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md index cfc11fc..f342768 100644 --- a/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md +++ b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md @@ -277,3 +277,49 @@ SHALL отображаться вне каркаса, но в общей тем #### Scenario: Смена альбома - **WHEN** пользователь переходит в другой альбом - **THEN** отметка выбора сбрасывается + +### Requirement: Мультивыбор фото в сетке +В режиме альбома пользователь SHALL выбирать несколько фото: обычный клик — одно фото, +Ctrl/Cmd-клик — добавить или убрать, Shift-клик — диапазон. Выбранные фото SHALL быть отмечены. + +#### Scenario: Мультивыбор +- **WHEN** пользователь кликает по фото с зажатым Ctrl/Cmd или Shift +- **THEN** соответствующие фото отмечены, а панель показывает количество выбранных + +### Requirement: Действия с выбранными фото +Когда выбрано одно или несколько фото, правая панель SHALL показывать раздел «Действия» со +строками-пунктами (иконка + подпись) «Открыть (просмотр)», «Переместить», «Копировать» и +«Удалить»; удаление SHALL подтверждаться диалогом. + +#### Scenario: Действия +- **WHEN** выбрано одно или несколько фото +- **THEN** панель показывает раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать» и «Удалить» + +### Requirement: Перетаскивание выбранных фото на альбом +Выбранные фото SHALL перетаскиваться на альбом в рейле; при отпускании SHALL открываться +диалог выбора переноса или копирования с указанием целевого альбома. Во время такого +перетаскивания зона загрузки файлов SHALL не показываться. + +#### Scenario: Перетаскивание на альбом +- **WHEN** пользователь перетаскивает выделенные фото на альбом в рейле и отпускает +- **THEN** открывается диалог с выбором переноса/копирования и целевым альбомом + +### Requirement: Корзина в списке альбомов +Рейл альбомов SHALL содержать вход в корзину последним элементом списка (с иконкой корзины). +Экран обзора альбомов SHALL не показывать пустую правую панель. + +#### Scenario: Вход в корзину +- **WHEN** пользователь открывает приложение +- **THEN** в левом рейле последним элементом списка показана «Корзина» с иконкой корзины + +#### Scenario: Обзор без панели +- **WHEN** пользователь открывает обзор альбомов +- **THEN** правая панель не отображается + +### Requirement: Компактный верхний хром +Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту +(узкие отступы, иконочные органы управления), сохраняя все действия доступными. + +#### Scenario: Компактные верхние панели +- **WHEN** открыт список альбомов или режим просмотра фото +- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны diff --git a/openspec/changes/lightroom-workspace-ui/tasks.md b/openspec/changes/lightroom-workspace-ui/tasks.md index 6d5cbfc..f0aec29 100644 --- a/openspec/changes/lightroom-workspace-ui/tasks.md +++ b/openspec/changes/lightroom-workspace-ui/tasks.md @@ -69,3 +69,10 @@ - [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12). - [x] 9.9 Выбор фото в сетке: одиночный клик выделяет фото рамкой и показывает его сведения в правой панели, двойной — открывает просмотр; стор `selection`, сброс при смене альбома. Проверка: Playwright — выделение без навигации, панель показывает инфо, двойной клик открывает просмотр (5/5). - [x] 9.10 Вьюер и модалки: убраны таймеры автоскрытия (панели/стрелки мгновенно), стрелки появляются только в границах вьюера, `f`/кнопка переключают вложенный fullscreen вьюера (не выключают режим приложения); модалки рендерятся порталом в `document.body` (центрирование во вьюпорте). Проверка: Playwright — стрелки 4/4, fullscreen+f 6/6, центрирование 2/2. +- [x] 9.11 Корзина: миграция `000005_photo_trash` (`deleted_at`), мягкое удаление (`DELETE /photos/:id`), `GET /trash`, `POST /photos/:id/restore`, `DELETE /photos/:id/permanent` с удалением объектов S3 только при отсутствии ссылок на `storage_key`. Проверка: удаление → в корзине, восстановление возвращает, копия сохраняет объект. +- [x] 9.12 Перенос/копирование: `POST /photo-batch/{delete,move,copy}`, клон записи с общими объектами S3 (метаданные), события SSE для источника и цели. Проверка: копия даёт N фото в цели, перенос убирает из источника. +- [x] 9.13 Фронтенд: стор `selection` (Ctrl/Shift-мультивыбор), действия в панели «Удалить/Переместить/Копировать», drag выделенных на альбом рейла, глобальный `TransferDialog`. Проверка: Playwright — мультивыбор и copy 9/9, drag+move 4/4. +- [x] 9.14 Рейл: вход «Корзина» последним элементом списка с иконкой, скрытие пустой правой панели (`panelVisible`), маршрут `/trash` с восстановлением и окончательным удалением. Проверка: в рейле есть вход в корзину, на обзоре нет правой панели. +- [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`, компактный переключатель темы). Проверка: высота `
      ` ~31px, верхней панели просмотра ~29px; `npm run check`.