From 304cfedb75f0982423cfc639790d7bd1fc511c50 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 18:15:13 +0300 Subject: [PATCH 01/18] feat: lightroom-style workspace UI with album covers Backend: - expose cover thumbnail URL on album responses (LEFT JOIN + presign) - auto-assign cover only when empty; FK cover_photo_id -> photos ON DELETE SET NULL - add photos_count / members_count to album responses; cover repo test Frontend: - split layouts into (app) shell (resizable rail + contextual panel) and (public) - album rail + create-album dialog; album panel with cover picker / make-cover - photo viewer route with filmstrip, replacing lightbox; share reuses it - grid density slider, toolbar + drag-and-drop upload, dark graphite default OpenSpec: add lightroom-workspace-ui change (proposal, design, specs, tasks) --- backend/internal/albums/service.go | 30 ++- backend/internal/albums/store.go | 19 +- backend/internal/models/models.go | 18 +- backend/internal/photos/service.go | 2 +- backend/internal/photos/store.go | 6 +- backend/internal/repo/repo_test.go | 66 +++++ .../migrations/000002_album_cover_fk.down.sql | 1 + .../migrations/000002_album_cover_fk.up.sql | 9 + frontend/src/app.css | 16 +- frontend/src/app.html | 4 +- frontend/src/lib/components/AlbumPanel.svelte | 165 +++++++++++++ frontend/src/lib/components/AlbumRail.svelte | 78 ++++++ frontend/src/lib/components/AppShell.svelte | 200 +++++++++++++++ frontend/src/lib/components/Comments.svelte | 10 +- .../src/lib/components/CoverPicker.svelte | 112 +++++++++ .../lib/components/CreateAlbumDialog.svelte | 88 +++++++ .../src/lib/components/DensityControl.svelte | 19 ++ frontend/src/lib/components/Lightbox.svelte | 131 ---------- frontend/src/lib/components/PhotoGrid.svelte | 6 +- frontend/src/lib/components/Uploader.svelte | 104 +++----- frontend/src/lib/components/Viewer.svelte | 149 ++++++++++++ frontend/src/lib/icons/registry.ts | 5 +- frontend/src/lib/stores/albums.svelte.ts | 62 +++++ frontend/src/lib/stores/density.svelte.ts | 20 ++ frontend/src/lib/stores/theme.svelte.ts | 6 +- frontend/src/lib/types.ts | 3 + frontend/src/routes/(app)/+layout.svelte | 17 ++ frontend/src/routes/(app)/+page.svelte | 73 ++++++ .../src/routes/(app)/albums/[id]/+page.svelte | 230 ++++++++++++++++++ .../albums/[id]/members/+page.svelte | 0 .../albums/[id]/photos/[photoId]/+page.svelte | 83 +++++++ .../albums/[id]/settings/+page.svelte | 0 frontend/src/routes/(public)/+layout.svelte | 21 ++ .../invites/[token]/+page.svelte | 0 .../routes/{ => (public)}/login/+page.svelte | 0 .../{ => (public)}/share/[token]/+page.svelte | 41 +--- frontend/src/routes/+layout.svelte | 49 +--- frontend/src/routes/+page.svelte | 107 -------- frontend/src/routes/albums/[id]/+page.svelte | 186 -------------- frontend/tailwind.config.ts | 2 + .../lightroom-workspace-ui/.openspec.yaml | 2 + .../changes/lightroom-workspace-ui/design.md | 128 ++++++++++ .../lightroom-workspace-ui/proposal.md | 64 +++++ .../specs/albums/spec.md | 31 +++ .../specs/photos/spec.md | 15 ++ .../specs/ui-workspace/spec.md | 121 +++++++++ .../changes/lightroom-workspace-ui/tasks.md | 44 ++++ 47 files changed, 1942 insertions(+), 601 deletions(-) create mode 100644 backend/migrations/000002_album_cover_fk.down.sql create mode 100644 backend/migrations/000002_album_cover_fk.up.sql create mode 100644 frontend/src/lib/components/AlbumPanel.svelte create mode 100644 frontend/src/lib/components/AlbumRail.svelte create mode 100644 frontend/src/lib/components/AppShell.svelte create mode 100644 frontend/src/lib/components/CoverPicker.svelte create mode 100644 frontend/src/lib/components/CreateAlbumDialog.svelte create mode 100644 frontend/src/lib/components/DensityControl.svelte delete mode 100644 frontend/src/lib/components/Lightbox.svelte create mode 100644 frontend/src/lib/components/Viewer.svelte create mode 100644 frontend/src/lib/stores/albums.svelte.ts create mode 100644 frontend/src/lib/stores/density.svelte.ts create mode 100644 frontend/src/routes/(app)/+layout.svelte create mode 100644 frontend/src/routes/(app)/+page.svelte create mode 100644 frontend/src/routes/(app)/albums/[id]/+page.svelte rename frontend/src/routes/{ => (app)}/albums/[id]/members/+page.svelte (100%) create mode 100644 frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte rename frontend/src/routes/{ => (app)}/albums/[id]/settings/+page.svelte (100%) create mode 100644 frontend/src/routes/(public)/+layout.svelte rename frontend/src/routes/{ => (public)}/invites/[token]/+page.svelte (100%) rename frontend/src/routes/{ => (public)}/login/+page.svelte (100%) rename frontend/src/routes/{ => (public)}/share/[token]/+page.svelte (64%) delete mode 100644 frontend/src/routes/+page.svelte delete mode 100644 frontend/src/routes/albums/[id]/+page.svelte create mode 100644 openspec/changes/lightroom-workspace-ui/.openspec.yaml create mode 100644 openspec/changes/lightroom-workspace-ui/design.md create mode 100644 openspec/changes/lightroom-workspace-ui/proposal.md create mode 100644 openspec/changes/lightroom-workspace-ui/specs/albums/spec.md create mode 100644 openspec/changes/lightroom-workspace-ui/specs/photos/spec.md create mode 100644 openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md create mode 100644 openspec/changes/lightroom-workspace-ui/tasks.md diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index 168b0f4..f77b3c2 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -51,7 +51,24 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m } func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) { - return s.store.ListForUser(ctx, userID) + out, err := s.store.ListForUser(ctx, userID) + if err != nil { + return nil, err + } + for i := range out { + s.attachCover(ctx, &out[i]) + } + return out, nil +} + +// attachCover fills CoverThumbURL from the presigned thumbnail key, when present. +func (s *Service) attachCover(ctx context.Context, a *models.Album) { + if s.storage == nil || a == nil || a.CoverThumbKey == "" { + return + } + if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil { + a.CoverThumbURL = url + } } func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) { @@ -76,7 +93,12 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A } func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) { - return s.Access(ctx, albumID, userID) + a, role, err := s.Access(ctx, albumID, userID) + if err != nil { + return nil, "", err + } + s.attachCover(ctx, a) + return a, role, nil } type UpdateInput struct { @@ -112,6 +134,10 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI if err := s.store.Update(ctx, a); err != nil { return nil, err } + if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil { + a = refreshed + } + s.attachCover(ctx, a) s.hub.Publish(albumID, "album.updated", a) return a, nil } diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go index 3d947d8..1344803 100644 --- a/backend/internal/albums/store.go +++ b/backend/internal/albums/store.go @@ -43,9 +43,14 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) { func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { var a models.Album err := s.pool.QueryRow(ctx, - `SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at - FROM albums WHERE id = $1`, id). - Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt) + `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, + COALESCE(p.thumb_key, ''), + (SELECT count(*) FROM photos WHERE album_id = a.id), + (SELECT count(*) FROM album_members WHERE album_id = a.id) + FROM albums a + LEFT JOIN photos p ON p.id = a.cover_photo_id + WHERE a.id = $1`, id). + Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -57,9 +62,13 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) { rows, err := s.pool.Query(ctx, - `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at + `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, + COALESCE(p.thumb_key, ''), + (SELECT count(*) FROM photos WHERE album_id = a.id), + (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1 + LEFT JOIN photos p ON p.id = a.cover_photo_id WHERE a.owner_id = $1 OR m.user_id IS NOT NULL ORDER BY a.created_at DESC`, userID) if err != nil { @@ -70,7 +79,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, out := []models.Album{} for rows.Next() { var a models.Album - if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil { + if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil { return nil, err } out = append(out, a) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 7456174..eb9b21e 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -26,13 +26,17 @@ func (u *User) Public() PublicUser { } type Album struct { - ID string `json:"id"` - OwnerID string `json:"owner_id"` - Title string `json:"title"` - Description string `json:"description"` - CoverPhotoID *string `json:"cover_photo_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + OwnerID string `json:"owner_id"` + Title string `json:"title"` + Description string `json:"description"` + CoverPhotoID *string `json:"cover_photo_id,omitempty"` + CoverThumbKey string `json:"-"` + CoverThumbURL string `json:"cover_thumb_url,omitempty"` + PhotosCount int `json:"photos_count"` + MembersCount int `json:"members_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type AlbumMember struct { diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 7696dce..9f3482d 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -121,7 +121,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) { if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil { return } - s.store.SetCover(ctx, albumID, photoID) + s.store.SetCoverIfEmpty(ctx, albumID, photoID) if p, err := s.store.GetByID(ctx, photoID, ""); err == nil { if err := s.attachURLs(ctx, p); err == nil { s.hub.Publish(albumID, "photo.created", p) diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 8937da9..848e630 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -122,6 +122,8 @@ func (s *Store) Delete(ctx context.Context, id string) error { return nil } -func (s *Store) SetCover(ctx context.Context, albumID, photoID string) { - _, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID) +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`, + albumID, photoID) } diff --git a/backend/internal/repo/repo_test.go b/backend/internal/repo/repo_test.go index 14d23df..9449131 100644 --- a/backend/internal/repo/repo_test.go +++ b/backend/internal/repo/repo_test.go @@ -12,6 +12,7 @@ import ( "shared-album/internal/auth" "shared-album/internal/members" "shared-album/internal/models" + "shared-album/internal/photos" "shared-album/internal/sharelinks" ) @@ -123,6 +124,71 @@ func TestAlbumRepository(t *testing.T) { } } +func TestAlbumCoverRepository(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + store := albums.NewStore(pool) + + owner := newUser(t, ctx, pool, "CoverOwner") + t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) }) + + a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"} + if err := store.Create(ctx, a); err != nil { + t.Fatalf("create album: %v", err) + } + + insertPhoto := func(id, thumbKey string) { + t.Helper() + if _, err := pool.Exec(ctx, + `INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key) + VALUES ($1, $2, $3, $4, $5)`, + id, a.ID, owner.ID, "albums/"+a.ID+"/original/"+id+".jpg", thumbKey); err != nil { + t.Fatalf("insert photo: %v", err) + } + } + + photo1 := auth.NewID() + insertPhoto(photo1, "albums/"+a.ID+"/thumb/"+photo1+".webp") + + got, err := store.GetByID(ctx, a.ID) + if err != nil || got.CoverThumbKey != "" { + t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey) + } + + ps := photos.NewStore(pool) + ps.SetCoverIfEmpty(ctx, a.ID, photo1) + got, _ = store.GetByID(ctx, a.ID) + if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 { + t.Fatalf("cover not set: %+v", got.CoverPhotoID) + } + if want := "albums/" + a.ID + "/thumb/" + photo1 + ".webp"; got.CoverThumbKey != want { + t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, want) + } + + photo2 := auth.NewID() + insertPhoto(photo2, "thumb-2") + ps.SetCoverIfEmpty(ctx, a.ID, photo2) + got, _ = store.GetByID(ctx, a.ID) + if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 { + t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID) + } + + if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil { + t.Fatalf("delete cover photo: %v", err) + } + got, _ = store.GetByID(ctx, a.ID) + if got.CoverPhotoID != nil { + t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID) + } + + ps.SetCoverIfEmpty(ctx, a.ID, photo2) + list, err := store.ListForUser(ctx, owner.ID) + if err != nil || len(list) != 1 || list[0].CoverThumbKey != "thumb-2" { + t.Fatalf("list cover key: %v %+v", err, list) + } +} + func TestMemberRepository(t *testing.T) { pool := testPool(t) defer pool.Close() diff --git a/backend/migrations/000002_album_cover_fk.down.sql b/backend/migrations/000002_album_cover_fk.down.sql new file mode 100644 index 0000000..c3c858f --- /dev/null +++ b/backend/migrations/000002_album_cover_fk.down.sql @@ -0,0 +1 @@ +ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk; diff --git a/backend/migrations/000002_album_cover_fk.up.sql b/backend/migrations/000002_album_cover_fk.up.sql new file mode 100644 index 0000000..130250f --- /dev/null +++ b/backend/migrations/000002_album_cover_fk.up.sql @@ -0,0 +1,9 @@ +-- Clear any dangling cover references before adding the constraint. +UPDATE albums +SET cover_photo_id = NULL +WHERE cover_photo_id IS NOT NULL + AND cover_photo_id NOT IN (SELECT id FROM photos); + +ALTER TABLE albums + ADD CONSTRAINT albums_cover_photo_fk + FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL; diff --git a/frontend/src/app.css b/frontend/src/app.css index 024fe6c..6a72e6b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -15,22 +15,26 @@ --primary-fg: 0 0% 100%; --danger: 0 84% 60%; --success: 142 71% 45%; + --chrome: 0 0% 98%; + --canvas: 0 0% 100%; --shadow: 0 0% 0% / 0.08; } -/* Тёмная */ +/* Тёмная (графит Lightroom) */ [data-theme='dark'] { - --bg: 0 0% 7%; - --bg-elevated: 0 0% 11%; - --bg-muted: 0 0% 16%; + --bg: 0 0% 9%; + --bg-elevated: 0 0% 15%; + --bg-muted: 0 0% 18%; --border: 0 0% 22%; - --text: 0 0% 96%; + --text: 0 0% 92%; --text-muted: 0 0% 65%; --text-subtle: 0 0% 45%; --primary: 217 91% 60%; --primary-fg: 0 0% 100%; --danger: 0 84% 65%; --success: 142 65% 50%; + --chrome: 0 0% 13%; + --canvas: 0 0% 9%; --shadow: 0 0% 0% / 0.4; } @@ -47,6 +51,8 @@ --primary-fg: 0 0% 100%; --danger: 0 72% 55%; --success: 142 55% 42%; + --chrome: 40 22% 92%; + --canvas: 40 20% 96%; --shadow: 30 20% 20% / 0.12; } diff --git a/frontend/src/app.html b/frontend/src/app.html index d32d6e1..e616e5c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,5 +1,5 @@ - + @@ -7,7 +7,7 @@ + +{#if photoId} + {#if photo} +
+

+ {photo.original_filename} +

+
+
Автор{photo.uploader.name}
+ {#if photo.taken_at} +
+ Снято + {new Date(photo.taken_at).toLocaleString()} +
+ {/if} +
+ Размер + {photo.width}×{photo.height} +
+
+
+ + Комментарии + + +
+
+ {/if} +{:else if album} +
+
+ + Обложка + +
+ {#if album.cover_thumb_url} + + {:else} + + + + {/if} +
+ {#if canEdit} +
+ + {#if album.cover_photo_id} + + {/if} +
+ {/if} +
+ +
+

{album.title}

+ {#if album.description}

{album.description}

{/if} +

+ {album.photos_count ?? 0} фото · {album.members_count ?? 0} участников +

+ {#if error}

{error}

{/if} +
+ + +
+ + {#if picking} + (picking = false)} + onSelected={(updated) => (album = updated)} + /> + {/if} +{/if} diff --git a/frontend/src/lib/components/AlbumRail.svelte b/frontend/src/lib/components/AlbumRail.svelte new file mode 100644 index 0000000..168ff32 --- /dev/null +++ b/frontend/src/lib/components/AlbumRail.svelte @@ -0,0 +1,78 @@ + + +
+
+ Альбомы + {#if auth.isAuthenticated} + + {/if} +
+ + {#if !auth.isAuthenticated} +

Войдите, чтобы видеть альбомы.

+ {:else if albums.loading && albums.list.length === 0} +

Загрузка…

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

Альбомов пока нет.

+ {:else} + + {/if} + + {#if creating} + (creating = false)} + onCreated={(album) => goto(`/albums/${album.id}`)} + /> + {/if} +
diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..795d5a6 --- /dev/null +++ b/frontend/src/lib/components/AppShell.svelte @@ -0,0 +1,200 @@ + + +
+
+
+ + + + Galo + +
+
+ {#if panel} + + {/if} + {#if auth.isAuthenticated} + + + {:else} + + + + + {/if} + +
+
+ +
+ {#if railOpen} + + {/if} + + + + +
+ {@render children()} +
+ + {#if panel} + {#if panelOpen} + + {/if} + + + {/if} +
+
diff --git a/frontend/src/lib/components/Comments.svelte b/frontend/src/lib/components/Comments.svelte index 93a0acd..41a51bc 100644 --- a/frontend/src/lib/components/Comments.svelte +++ b/frontend/src/lib/components/Comments.svelte @@ -75,18 +75,20 @@ {/each} {#if auth.isAuthenticated} -
+
{/if} diff --git a/frontend/src/lib/components/CoverPicker.svelte b/frontend/src/lib/components/CoverPicker.svelte new file mode 100644 index 0000000..cc0f7d5 --- /dev/null +++ b/frontend/src/lib/components/CoverPicker.svelte @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/lib/components/CreateAlbumDialog.svelte b/frontend/src/lib/components/CreateAlbumDialog.svelte new file mode 100644 index 0000000..3e567c6 --- /dev/null +++ b/frontend/src/lib/components/CreateAlbumDialog.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/lib/components/DensityControl.svelte b/frontend/src/lib/components/DensityControl.svelte new file mode 100644 index 0000000..d00ba10 --- /dev/null +++ b/frontend/src/lib/components/DensityControl.svelte @@ -0,0 +1,19 @@ + + +
+ + density.set(Number(e.currentTarget.value))} + class="accent-primary h-1 w-24 cursor-pointer" + aria-label="Размер миниатюр" + title="Размер миниатюр" + /> +
diff --git a/frontend/src/lib/components/Lightbox.svelte b/frontend/src/lib/components/Lightbox.svelte deleted file mode 100644 index 5af7be3..0000000 --- a/frontend/src/lib/components/Lightbox.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte index b7bbcb6..bc5bacc 100644 --- a/frontend/src/lib/components/PhotoGrid.svelte +++ b/frontend/src/lib/components/PhotoGrid.svelte @@ -1,5 +1,6 @@ -
{ - e.preventDefault(); - dragging = true; - }} - ondragleave={() => (dragging = false)} - ondrop={onDrop} - ondragenter={(e) => e.preventDefault()} - role="region" - aria-label="Загрузка фотографий" -> - - -

Перетащите фото сюда или

- + - {#if busy} -
+{#if busy} +
+

Загрузка… {progress}%

+
- {/if} - {#if error} -

{error}

- {/if} -
+
+{/if} +{#if error} + +{/if} diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte new file mode 100644 index 0000000..d9b1d41 --- /dev/null +++ b/frontend/src/lib/components/Viewer.svelte @@ -0,0 +1,149 @@ + + + + +
+
+ {index + 1} / {photos.length} +
+ {#if canEdit} + + {/if} + {#if fresh?.download_url} + + + + + {/if} + +
+
+ +
+ + {#if current} + {current.original_filename} + {/if} + +
+ +
+ {#each photos as photo, i (photo.id)} + + {/each} +
+
diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 55b0060..1b2e84e 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -40,7 +40,10 @@ export const icons = { link_off: { path: '' }, delete_forever: { path: '' }, content_copy: { path: '' }, - check: { path: '' } + check: { path: '' }, + menu: { path: '' }, + grid_view: { path: '' }, + wallpaper: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; diff --git a/frontend/src/lib/stores/albums.svelte.ts b/frontend/src/lib/stores/albums.svelte.ts new file mode 100644 index 0000000..e9784b7 --- /dev/null +++ b/frontend/src/lib/stores/albums.svelte.ts @@ -0,0 +1,62 @@ +import { api } from '$lib/api'; +import type { Album } from '$lib/types'; +import { auth } from './auth.svelte'; + +class AlbumsStore { + list = $state([]); + loading = $state(false); + loaded = $state(false); + error = $state(''); + + async load() { + if (!auth.isAuthenticated) { + this.list = []; + this.loaded = true; + return; + } + this.loading = true; + this.error = ''; + try { + const data = await api<{ albums: Album[] }>('/albums'); + this.list = data.albums; + this.loaded = true; + } catch (e) { + this.error = (e as Error).message; + } finally { + this.loading = false; + } + } + + async create(title: string): Promise { + const data = await api<{ album: Album }>('/albums', { method: 'POST', body: { title } }); + this.list = [data.album, ...this.list]; + return data.album; + } + + async setCover(albumId: string, coverPhotoId: string | null): Promise { + const body = coverPhotoId ? { cover_photo_id: coverPhotoId } : { clear_cover: true }; + const data = await api<{ album: Album }>(`/albums/${albumId}`, { method: 'PATCH', body }); + this.upsert(data.album); + return data.album; + } + + upsert(album: Album) { + const i = this.list.findIndex((a) => a.id === album.id); + if (i === -1) { + this.list = [album, ...this.list]; + } else { + this.list = this.list.map((a) => (a.id === album.id ? album : a)); + } + } + + remove(id: string) { + this.list = this.list.filter((a) => a.id !== id); + } + + reset() { + this.list = []; + this.loaded = false; + } +} + +export const albums = new AlbumsStore(); diff --git a/frontend/src/lib/stores/density.svelte.ts b/frontend/src/lib/stores/density.svelte.ts new file mode 100644 index 0000000..1a7c555 --- /dev/null +++ b/frontend/src/lib/stores/density.svelte.ts @@ -0,0 +1,20 @@ +const KEY = 'grid-cols'; +const MIN = 2; +const MAX = 8; + +class DensityStore { + cols = $state(5); + + init() { + if (typeof window === 'undefined') return; + const v = parseInt(localStorage.getItem(KEY) ?? '', 10); + if (v >= MIN && v <= MAX) this.cols = v; + } + + set(value: number) { + this.cols = Math.min(MAX, Math.max(MIN, Math.round(value))); + if (typeof window !== 'undefined') localStorage.setItem(KEY, String(this.cols)); + } +} + +export const density = new DensityStore(); diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index b432c3f..99f2ee8 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -14,13 +14,13 @@ function resolve(theme: Theme): Resolved { } class ThemeStore { - current = $state('system'); - resolved = $state('light'); + current = $state('dark'); + resolved = $state('dark'); private media: MediaQueryList | null = null; init() { if (typeof window === 'undefined') return; - const stored = (localStorage.getItem(KEY) as Theme | null) ?? 'system'; + const stored = (localStorage.getItem(KEY) as Theme | null) ?? 'dark'; this.current = stored; this.apply(); this.media = window.matchMedia('(prefers-color-scheme: dark)'); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0f00e5d..1fe6b33 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -11,6 +11,9 @@ export interface Album { title: string; description: string; cover_photo_id?: string | null; + cover_thumb_url?: string; + photos_count?: number; + members_count?: number; created_at: string; updated_at: string; } diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..10f5422 --- /dev/null +++ b/frontend/src/routes/(app)/+layout.svelte @@ -0,0 +1,17 @@ + + + + {#snippet rail()} + + {/snippet} + {#snippet panel()} + + {/snippet} + {@render children()} + diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..3323e8a --- /dev/null +++ b/frontend/src/routes/(app)/+page.svelte @@ -0,0 +1,73 @@ + + +{#if !auth.isAuthenticated} +
+

Общие фотоальбомы

+

Войдите, чтобы создавать альбомы и делиться фотографиями.

+ + + Войти + +
+{:else} +
+
+

Мои альбомы

+ +
+ + {#if albums.loading && albums.list.length === 0} +

Загрузка…

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

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

+
+ {:else} + + {/if} +
+{/if} diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte new file mode 100644 index 0000000..8d1d3c5 --- /dev/null +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -0,0 +1,230 @@ + + +{#if loading} +

Загрузка…

+{:else if error} +

{error}

+{:else} +
{ + if (!canUpload) return; + e.preventDefault(); + dragging = true; + }} + ondragleave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) dragging = false; + }} + ondrop={onDrop} + > +
+

{album?.title ?? ''}

+
+ + {#if canUpload} + + {/if} +
+
+ +
+ {#if canUpload && photos.length === 0} + + {:else} + { + const p = photos[i]; + if (p) goto(`/albums/${albumId}/photos/${p.id}`); + }} + /> + {/if} + + {#if nextCursor} +
+ +
+ {/if} +
+ + {#if dragging} +
+ + + Отпустите, чтобы загрузить + +
+ {/if} + + { + for (const p of created) { + if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos]; + } + }} + /> +
+{/if} diff --git a/frontend/src/routes/albums/[id]/members/+page.svelte b/frontend/src/routes/(app)/albums/[id]/members/+page.svelte similarity index 100% rename from frontend/src/routes/albums/[id]/members/+page.svelte rename to frontend/src/routes/(app)/albums/[id]/members/+page.svelte diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte new file mode 100644 index 0000000..ba1dd48 --- /dev/null +++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte @@ -0,0 +1,83 @@ + + +{#if loading} +

Загрузка…

+{:else if error} +

{error}

+{:else if index < 0} +
+

Фото не найдено.

+ К альбому +
+{:else} +
+ {#if saved} +

Обложка обновлена

+ {/if} +
+ +
+
+{/if} diff --git a/frontend/src/routes/albums/[id]/settings/+page.svelte b/frontend/src/routes/(app)/albums/[id]/settings/+page.svelte similarity index 100% rename from frontend/src/routes/albums/[id]/settings/+page.svelte rename to frontend/src/routes/(app)/albums/[id]/settings/+page.svelte diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte new file mode 100644 index 0000000..a14569d --- /dev/null +++ b/frontend/src/routes/(public)/+layout.svelte @@ -0,0 +1,21 @@ + + +
+
+ +
+
+ {@render children()} +
+
diff --git a/frontend/src/routes/invites/[token]/+page.svelte b/frontend/src/routes/(public)/invites/[token]/+page.svelte similarity index 100% rename from frontend/src/routes/invites/[token]/+page.svelte rename to frontend/src/routes/(public)/invites/[token]/+page.svelte diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte similarity index 100% rename from frontend/src/routes/login/+page.svelte rename to frontend/src/routes/(public)/login/+page.svelte diff --git a/frontend/src/routes/share/[token]/+page.svelte b/frontend/src/routes/(public)/share/[token]/+page.svelte similarity index 64% rename from frontend/src/routes/share/[token]/+page.svelte rename to frontend/src/routes/(public)/share/[token]/+page.svelte index 89e28cd..234b2be 100644 --- a/frontend/src/routes/share/[token]/+page.svelte +++ b/frontend/src/routes/(public)/share/[token]/+page.svelte @@ -3,6 +3,7 @@ import { api } from '$lib/api'; import Icon from '$lib/components/Icon.svelte'; import PhotoGrid from '$lib/components/PhotoGrid.svelte'; + import Viewer from '$lib/components/Viewer.svelte'; import type { Album, Photo } from '$lib/types'; const token = $derived($page.params.token); @@ -65,41 +66,13 @@ {/if} {#if active >= 0} - {/if} {:else} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 202a21b..1deca08 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,18 +1,17 @@ -
-
-
- - - Galo - -
- {#if auth.isAuthenticated} - - - {:else} - - - Войти - - {/if} - -
-
-
-
- {@render children()} -
-
+{@render children()} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte deleted file mode 100644 index 0ce6b47..0000000 --- a/frontend/src/routes/+page.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - -{#if !auth.isAuthenticated} -
-

Общие фотоальбомы

-

Войдите, чтобы создавать альбомы и делиться фотографиями.

- - - Войти - -
-{:else} -
-

Мои альбомы

-
- - -
-
- - {#if error}

{error}

{/if} - - {#if loading} -

Загрузка…

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

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

-
- {:else} - - {/if} -{/if} diff --git a/frontend/src/routes/albums/[id]/+page.svelte b/frontend/src/routes/albums/[id]/+page.svelte deleted file mode 100644 index f737f70..0000000 --- a/frontend/src/routes/albums/[id]/+page.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - -{#if loading} -

Загрузка…

-{:else if error} -

{error}

-{:else if album} -
-
-

{album.title}

-

{album.description}

-
-
- - - Участники - - {#if role === 'owner' || role === 'admin'} - - - Настройки - - {/if} -
-
- - {#if canUpload} -
- { - for (const p of created) { - if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos]; - } - }} - /> -
- {/if} - - (lightboxIndex = i)} /> - - {#if nextCursor} -
- -
- {/if} - - {#if lightboxIndex >= 0} - (lightboxIndex = -1)} - onIndexChange={(i) => (lightboxIndex = i)} - /> - {/if} -{/if} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 80b45ff..4594d69 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -11,6 +11,8 @@ export default { muted: 'hsl(var(--bg-muted) / )' }, border: 'hsl(var(--border) / )', + chrome: 'hsl(var(--chrome) / )', + canvas: 'hsl(var(--canvas) / )', text: { DEFAULT: 'hsl(var(--text) / )', muted: 'hsl(var(--text-muted) / )', diff --git a/openspec/changes/lightroom-workspace-ui/.openspec.yaml b/openspec/changes/lightroom-workspace-ui/.openspec.yaml new file mode 100644 index 0000000..53f62d7 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/lightroom-workspace-ui/design.md b/openspec/changes/lightroom-workspace-ui/design.md new file mode 100644 index 0000000..4f203d4 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/design.md @@ -0,0 +1,128 @@ +# Design + +## Context + +См. proposal.md — Why. Текущее состояние, которое формирует подход: + +- `albums.cover_photo_id` уже существует (`migrations/000001_init.up.sql`), `PATCH /albums/:id` + принимает `cover_photo_id`/`clear_cover` (`albums/handlers.go:55`), но список и `GET` + отдают только id — URL миниатюры негде взять, поэтому карточки рисуют иконку-заглушку. +- `photos.Service.attachURLs` уже пресигнит `ThumbKey`/`PreviewKey` (`photos/service.go:132`), + а `albums.Service` держит `storage *storage.Client` — приём с presigned URL доступен и там. +- Фоновая обработка загруженного фото безусловно зовёт `photos.Store.SetCover` + (`photos/service.go:124`), перезаписывая выбор пользователя. +- FK на `cover_photo_id` нет; удаление фото-обложки оставляет висячий id. +- Фронтенд: один плоский `+layout.svelte` (топбар + `max-w-6xl` + центр), лайтбокс — + оверлей, на `share/[token]` свой инлайн-лайтбокс. Темы: light/dark/neutral/system, дефолт + `system` (`stores/theme.svelte.ts`). + +## Goals / Non-Goals + +**Goals:** +- Постоянный каркас с рейлом и контекстной панелью, центр = текущий маршрут (Q). +- Обложки доезжают до UI; явный выбор + разумный дефолт; корректное удаление. +- Тёмная графитовая тема по умолчанию при сохранении остальных тем. +- Просмотр фото как маршрут в каркасе, общий с публичной share-страницей. +- Адаптивность до мобильного без потери каркаса. + +**Non-Goals:** +- Мультивыбор фото и массовые действия / bulk-удаление. +- Замена роутинга на «чистые панели» без маршрутов (вариант P). +- Новые внешние зависимости (иконки и токены вендорятся/расширяются). +- Изменение доменных контрактов API (только добавление поля ответа и FK). + +## Decisions + +### 1. Каркас: группы маршрутов, центр = маршрут +`+layout.svelte` разбивается на группы: `(app)` — каркас с топбаром, левым рейлом и правой +панелью, и `(public)` — только токены. Центр `(app)` рендерит текущий маршрут; рейл живёт +между навигациями. +Альтернатива — один layout с условиями по `$page.route.id` (проще, но разрастается) или P +(панели вместо маршрутов) — отклонена: ломает deeplink и историю. + +### 2. Обложка: LEFT JOIN + presign (A) +`albums.Store` добавляет `LEFT JOIN photos p ON p.id = a.cover_photo_id` и отдаёт +`cover_thumb_key`; `albums.Service` пресигнит его в `Album.CoverThumbURL`. Один запрос и один +presign на альбом; висячий id безопасно даёт `NULL`. Альтернативы: N+1 с `GET /photos/:id` +(chatty) и выделенный thumb-эндпоинт (расходится с текущим паттерном presigned URL). + +### 3. Автообложка: писать только если пусто +`processVariants` вызывает `SetCover` только при `cover_photo_id IS NULL`. Выбор пользователя +не слетает, дефолт появляется с первым фото. Альтернатива — дефолт на чтении (COALESCE) — +отклонена в пользу простоты записи/чтения; осознанно принимаем, что в пачке побеждает +фото, обработанное первым. + +### 4. Целостность обложки: FK ON DELETE SET NULL (M) +Миграция `000002_album_cover_fk`: сперва обнуляет уже висячие `cover_photo_id`, затем +`ALTER TABLE albums ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id) +ON DELETE SET NULL`. БД сама чинит любой путь удаления. Циклический FK `albums <-> photos` +безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже. +Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления). + +### 5. Точки выбора обложки: панель + просмотр (T1+T2) +Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`; +в просмотре — действие «Сделать обложкой» для текущего фото. Всё под `roles.CanEditAlbum`. +Hover-оверлей в сетке (T3) не берём — шум и случайные клики. + +### 6. Тема: сохранить темы, dark = графит и дефолт (α) +Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию +(`theme.current = 'dark'` при отсутствии сохранённого выбора). Добавляются аддитивные токены +`--chrome` (топбар/рейл/панель) и `--canvas` (центр), чтобы «утопить» контент между панелями. +`light`/`neutral`/`system` остаются. Спека `ui-iconography` не затрагивается. + +### 7. Адаптив: drawer + sheet + горизонтальная лента (M1) +Порог рабочего стола — `lg`/`md`. Ниже: рейл — off-canvas drawer по кнопке, правая панель — +сворачиваемая секция/bottom-sheet, filmstrip — горизонтальный скролл. Публичные экраны +получают ту же мобильную логику просмотра без рейла/панели. + +### 8. Плотность сетки, без выбора (D) +Регулятор размера миниатюр в тулбаре сетки управляет `grid-template-columns` (пресеты или +`minmax`), значение хранится в `localStorage`. Мультивыбор/bulk — вне объёма. + +### 9. Просмотр как маршрут (L2) +Новый маршрут `albums/[id]/photos/[photoId]` рендерит общий компонент `Viewer` (крупное фото, +filmstrip, панель метаданных/комментариев, тулбар). `share/[token]` переиспользует `Viewer` +без рейла/панели. Навигация синхронизирует маршрут и filmstrip; клавиши ←/→/Esc. + +### 10. Загрузка из тулбара (U1) +Иконочная кнопка загрузки (роль-gated) открывает файловый диалог; `dragover` на весь альбом +показывает полноэкранный оверлей; пустой альбом показывает центральную зону-подсказку. +Прогресс — компактная полоса/тост вместо крупного блока. + +### 11. Создание альбома через модалку +Кнопка `+` с хинтом (в рейле и/или топбаре) открывает модалку с полем названия; `POST +/albums`, созданный альбом становится активным. Инлайн-форма удаляется. + +## Risks / Trade-offs + +- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети; + при необходимости кэшировать URL на короткий TTL. +- [Presigned URL обложки истекает при долго открытой странице] → URL обновляется при загрузке + списка; при ошибке загрузки картинки показывать плейсхолдер. +- [Циклический FK albums↔photos] → миграция обнуляет висячие id до создания constraint; + каскадное удаление альбома обрабатывается БД (SET NULL по уже удаляемой строке безвреден). +- [Реструктуризация `+layout` задевает все маршруты] → выносить каркас инкрементально, группы + `(app)`/`(public)` в одном шаге, публичные страницы регрессионно проверяем. +- [Добавление токенов темы задевает все экраны] → токены аддитивны, старые классы продолжают + работать; проверка триад тем. +- [Пачка загрузок: обложкой становится первое обработанное фото] → принято; дальнейшие + загрузки обложку не трогают, пользователь может задать явно. +- [Дополнительное состояние в маршруте просмотра (filmstrip, клавиатура)] → единый компонент + `Viewer` с props и общим источником фото. + +## Migration Plan + +1. Миграция `000002_album_cover_fk`: `UPDATE albums SET cover_photo_id = NULL WHERE + cover_photo_id IS NOT NULL AND cover_photo_id NOT IN (SELECT id FROM photos);` затем + `ADD CONSTRAINT ... ON DELETE SET NULL`. Down — `DROP CONSTRAINT`. +2. Бэкенд: `models.Album` (+`CoverThumbURL`), `albums/store.go` (LEFT JOIN), `albums/service.go` + (enrich + presign), `photos/service.go` (SetCover только если NULL). +3. Фронтенд: каркас и маршруты, компоненты, токены, `types.ts`. +4. Откат: снять constraint (down-миграция); фронтенд откатывается независимо, поле ответа + игнорируется старым клиентом. + +## Open Questions + +- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не + меняет. +- Хинты: нативный `title` или кастомный тултип — косметика, спеки не меняет. diff --git a/openspec/changes/lightroom-workspace-ui/proposal.md b/openspec/changes/lightroom-workspace-ui/proposal.md new file mode 100644 index 0000000..c4e6a36 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/proposal.md @@ -0,0 +1,64 @@ +# Proposal + +## Why + +Сейчас интерфейс — это центрированный «маркетинговый» лейаут: список альбомов с +инлайн-формой создания, серая заглушка вместо обложек, отдельные страницы «Участники» и +«Настройки», лайтбокс-оверлей. Обложки в бэкенде уже есть (`albums.cover_photo_id`, +`PATCH /albums/:id`), но до UI не доходят. Пользователь хочет полноценный «рабочий стол» +в духе Adobe Lightroom: постоянный рейл альбомов, контекстная панель, плотная сетка, +режим просмотра как часть рабочего стола, обложки с явным выбором и единый тёмный +графитовый хром. + +## What Changes + +- **Рабочий стол (шелл).** Группа `(app)` с постоянным левым рейлом альбомов, топбаром и + контекстной правой панелью. Публичные маршруты (`/login`, `/invites/[token]`, + `/share/[token]`) остаются вне шелла, но в тех же токенах. +- **Создание альбома.** Инлайн-форма заменяется иконочной кнопкой с хинтом, открывающей + модалку ввода названия. +- **Обложки.** `Album` получает `cover_thumb_url`; карточки и рейл показывают реальную + миниатюру. Выбор обложки: пикер в правой панели и действие «сделать обложкой» в + просмотре. Автообложка пишется только когда обложка ещё не задана; FK + `albums.cover_photo_id -> photos(id) ON DELETE SET NULL` чинит висячие ссылки. +- **Просмотр как маршрут.** `Lightbox`-оверлей заменяется маршрутом + `/albums/:id/photos/:photoId` внутри шелла: крупное фото, filmstrip снизу, панель + метаданных/комментариев. `share/[token]` переиспользует тот же компонент. +- **Плотность сетки.** Слайдер размера миниатюр в тулбаре (состояние в localStorage). + Мультивыбор и массовые действия — вне объёма. +- **Загрузка.** Крупный dropzone заменяется кнопкой в тулбаре, полноэкранным + drag-overlay и dropzone-намёком только в пустом альбоме. +- **Тёмная тема.** Существующие 4 темы сохраняются; `dark` переодевается в графит + Lightroom и становится дефолтом; добавляются токены `--chrome` и `--canvas`. +- **Адаптив.** На узких экранах рейл становится off-canvas drawer, правая панель — + bottom-sheet, filmstrip — горизонтальным скроллом. + +## Capabilities + +### New Capabilities + +- `ui-workspace`: рабочий стол приложения — шелл, рейл альбомов, контекстная панель, + режим просмотра как маршрут, адаптивное поведение и тёмный хром. + +### Modified Capabilities + +- `albums`: отображение обложки в списке/карточках и явный выбор обложки; поведение + обложки при отсутствии выбора. +- `photos`: автоустановка обложки первым загруженным фото выполняется только если обложка + ещё не задана. + +## Impact + +- **Backend:** миграция FK `albums.cover_photo_id -> photos(id) ON DELETE SET NULL`; + `internal/albums/store.go` (LEFT JOIN `photos` → `cover_thumb_key` в `ListForUser` и + `GetByID`); `internal/albums/service.go` (presign → `CoverThumbURL`, обогащение + списка); `internal/models` (`Album.CoverThumbURL`); `internal/photos/service.go` + (`SetCover` только при `cover_photo_id IS NULL`). +- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`; новые + компоненты шелла, рейла, панели, модалки создания, пикера обложки, filmstrip и + просмотра; новый маршрут `albums/[id]/photos/[photoId]`; новые значения токенов темы; + `types.ts` (+`cover_thumb_url`); новые иконки в реестре. +- **Спеки:** `ui-workspace` (новая), `albums`, `photos` (дельты). `ui-iconography` не + затрагивается — иконки по-прежнему наследуют цвет темы. +- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих + изменений API нет. diff --git a/openspec/changes/lightroom-workspace-ui/specs/albums/spec.md b/openspec/changes/lightroom-workspace-ui/specs/albums/spec.md new file mode 100644 index 0000000..43a7d00 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/specs/albums/spec.md @@ -0,0 +1,31 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Отображение обложки альбома +Данные альбома SHALL включать URL миниатюры обложки, когда обложка задана, чтобы клиенты +могли показать её в списке, рейле и карточке. + +#### Scenario: Обложка задана +- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка +- **THEN** ответ содержит URL миниатюры обложки + +#### Scenario: Обложка отсутствует +- **WHEN** у альбома нет обложки или фото обложки удалено +- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер + +### Requirement: Выбор обложки +Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить +обложку; после сброса обложка определяется заново по умолчанию. + +#### Scenario: Назначение обложки +- **WHEN** владелец выбирает фото альбома как обложку +- **THEN** система сохраняет выбор и отражает его в списке и карточках + +#### Scenario: Сброс обложки +- **WHEN** админ сбрасывает обложку +- **THEN** система удаляет явный выбор, и обложка возвращается к значению по умолчанию + +#### Scenario: Нет прав на смену обложки +- **WHEN** участник без права редактирования пытается сменить обложку +- **THEN** система отклоняет запрос diff --git a/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md b/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md new file mode 100644 index 0000000..b31b173 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md @@ -0,0 +1,15 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Обложка по умолчанию +При обработке загруженного фото система SHALL назначать его обложкой альбома только если +обложка ещё не задана. + +#### 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 new file mode 100644 index 0000000..2de1845 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md @@ -0,0 +1,121 @@ +# Spec Delta + +## Purpose + +Рабочий стол веб-приложения: постоянный каркас с рейлом альбомов, контекстной панелью, +режимом просмотра фото и адаптивным поведением, обеспечивающий цельный «lightroom»-вид. + +## ADDED Requirements + +### Requirement: Рабочий стол приложения +Аутентифицированные экраны SHALL отображаться внутри постоянного каркаса с топбаром, левым +рейлом альбомов и контекстной правой панелью. Публичные экраны (вход, инвайт, share-ссылка) +SHALL отображаться вне каркаса, но в общей теме оформления. + +#### Scenario: Внутренний экран +- **WHEN** аутентифицированный пользователь открывает список альбомов или альбом +- **THEN** экран отрисован внутри каркаса с рейлом и панелью, рейл не перезагружается при переходе + +#### Scenario: Публичный экран +- **WHEN** пользователь открывает публичную share-ссылку без входа +- **THEN** экран отрисован без рейла и контекстной панели, но в тех же цветовых токенах + +### Requirement: Рейл альбомов +Рейл SHALL постоянно показывать список доступных альбомов с их обложками и отмечать +активный альбом. + +#### Scenario: Активный альбом +- **WHEN** пользователь находится в альбоме +- **THEN** соответствующий элемент рейла выделен, остальные показывают обложки или плейсхолдер + +### Requirement: Создание альбома +Создание альбома SHALL выполняться через иконочную кнопку с подсказкой, открывающую +модальное окно ввода названия, вместо инлайн-формы. + +#### Scenario: Создание через модалку +- **WHEN** пользователь нажимает кнопку создания, вводит название и подтверждает +- **THEN** система создаёт альбом и делает его активным + +#### Scenario: Пустое название +- **WHEN** пользователь подтверждает пустое название +- **THEN** создание не выполняется + +### Requirement: Контекстная панель альбома +Правая панель SHALL показывать превью обложки, название, счётчики фото и участников, +управление обложкой и переходы к участникам, настройкам и ссылкам. Элементы управления +обложкой SHALL быть доступны только owner и admin. + +#### Scenario: Владелец альбома +- **WHEN** owner открывает альбом +- **THEN** панель показывает управление обложкой и переходы к участникам и настройкам + +#### Scenario: Наблюдатель +- **WHEN** viewer открывает альбом +- **THEN** панель не показывает управление обложкой + +### Requirement: Режим просмотра фотографии +Просмотр фото SHALL быть отдельным маршрутом внутри каркаса с крупным изображением, +лентой миниатюр снизу и панелью метаданных и комментариев. Тот же режим SHALL +использоваться на публичных share-страницах. + +#### Scenario: Открытие фото +- **WHEN** пользователь выбирает фото в сетке +- **THEN** открывается маршрут фото в каркасе, рейл и панель остаются доступны, адрес отражает фото + +#### Scenario: Навигация в просмотре +- **WHEN** пользователь использует стрелки влево/вправо или ленту миниатюр +- **THEN** показывается соседнее фото, адрес обновляется + +#### Scenario: Публичный просмотр +- **WHEN** посетитель share-ссылки открывает фото +- **THEN** используется тот же режим просмотра без рейла и панели + +### Requirement: Плотность сетки +Сетка SHALL давать пользователю управлять размером миниатюр, и выбранный размер SHALL +сохраняться между сессиями. + +#### Scenario: Изменение размера +- **WHEN** пользователь двигает регулятор плотности +- **THEN** размер миниатюр меняется, значение сохраняется и восстанавливается при следующем открытии + +### Requirement: Загрузка фотографий из рабочего стола +Загрузка SHALL запускаться действием в тулбаре; перетаскивание файлов над альбомом SHALL +показывать оверлей и загружать их; пустой альбом SHALL показывать подсказку-зону загрузки. +Загрузка SHALL быть доступна только ролям с правом загрузки. + +#### Scenario: Перетаскивание +- **WHEN** пользователь с правом загрузки перетаскивает файлы над альбомом +- **THEN** показывается оверлей загрузки, и файлы загружаются + +#### Scenario: Пустой альбом +- **WHEN** пользователь открывает альбом без фото и имеет право загрузки +- **THEN** в центре показана зона-подсказка загрузки + +### Requirement: Тёмная графитовая тема +Тёмная тема SHALL использовать графитовый хром Lightroom и SHALL применяться по умолчанию; +остальные темы SHALL оставаться доступными. + +#### Scenario: Первый вход +- **WHEN** пользователь впервые открывает приложение без сохранённой темы +- **THEN** применяется тёмная графитовая тема + +#### Scenario: Смена темы +- **WHEN** пользователь выбирает другую тему +- **THEN** интерфейс и иконки переключаются согласованно и выбор сохраняется + +### Requirement: Адаптивность рабочего стола +На узких экранах рейл SHALL становиться выдвижной панелью, контекстная панель — +сворачиваемой секцией, а лента миниатюр — горизонтально прокручиваемой. + +#### Scenario: Узкий экран +- **WHEN** ширина окна меньше порога рабочего стола +- **THEN** рейл скрыт за кнопкой открытия, панель сворачиваема, лента прокручивается горизонтально + +### Requirement: Доступность рабочего стола +Иконочные органы управления каркаса SHALL иметь доступные имена, панели SHALL быть +достижимы с клавиатуры с видимым фокусом, анимации SHALL учитывать +предпочтение уменьшенного движения. + +#### Scenario: Клавиатурная навигация +- **WHEN** пользователь работает только с клавиатуры +- **THEN** органы управления каркаса достижимы, доступные имена присутствуют, фокус виден diff --git a/openspec/changes/lightroom-workspace-ui/tasks.md b/openspec/changes/lightroom-workspace-ui/tasks.md new file mode 100644 index 0000000..286df5d --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/tasks.md @@ -0,0 +1,44 @@ +# Tasks + +## 1. Backend: обложка и целостность + +- [x] 1.1 Добавить миграцию `000002_album_cover_fk` (up/down): обнулить висячие `cover_photo_id`, затем `ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL`. Проверка: `make migrate` применяется, `\d albums` показывает constraint, down-миграция снимает его. +- [x] 1.2 Добавить `CoverThumbURL` в `models.Album` (json `cover_thumb_url,omitempty`). Проверка: `cd backend && go build ./...` проходит. +- [x] 1.3 В `albums/store.go` добавить `LEFT JOIN photos p ON p.id = a.cover_photo_id` и выбирать `p.thumb_key AS cover_thumb_key` в `ListForUser` и `GetByID`. Проверка: запрос возвращает `cover_thumb_key` для альбома с обложкой и `NULL` без неё. +- [x] 1.4 В `albums/service.go` пресигнить `cover_thumb_key` в `CoverThumbURL` в `List` и `Get`. Проверка: `GET /api/albums` в ответе содержит `cover_thumb_url` у альбома с обложкой. +- [x] 1.5 В `photos/service.go` вызывать `SetCover` только при `cover_photo_id IS NULL`. Проверка: загрузка двух фото в новый альбом оставляет обложкой первое; после удаления фото-обложки cover становится `NULL`, следующая загрузка ставит новый. +- [x] 1.6 Покрыть тестами store-join обложки и автообложку; проверить `cd backend && go test ./...`. + +## 2. Frontend: темы и каркас + +- [x] 2.1 Добавить токены `--chrome`/`--canvas`, перенастроить `dark` в графит и сделать его дефолтом (при отсутствии сохранённого выбора). Проверка: `cd frontend && npm run check` проходит, все три темы визуально согласованы. +- [x] 2.2 Добавить `cover_thumb_url` в `lib/types.ts` и недостающие иконки (обложка, плотность/меню) в `lib/icons/registry.ts`. Проверка: `npm run check` без ошибок типов. +- [x] 2.3 Разбить `+layout.svelte` на группы `(app)`/`(public)` и ввести `AppShell` (топбар, слот рейла, слот панели, центр = маршрут). Проверка: навигация между альбомами не перезагружает рейл; `/login`, `/invites/[token]`, `/share/[token]` рендерятся без каркаса. +- [x] 2.4 Реализовать адаптивный каркас: рейл → drawer, панель → сворачиваемая секция, лента → горизонтальный скролл на узкой ширине. Проверка: при ширине меньше порога каркас управляем без горизонтального overflow. + +## 3. Рейл и создание альбома + +- [x] 3.1 Компонент `AlbumRail`: список альбомов с обложками, плейсхолдер, подсветка активного. Проверка: активный альбом выделен, остальные показывают обложку/плейсхолдер. +- [x] 3.2 Кнопка создания `+` с хинтом и модалка ввода названия (замена инлайн-формы). Проверка: создание альбома через модалку, новый альбом становится активным; пустое название не создаёт альбом. + +## 4. Контекстная панель и обложки + +- [x] 4.1 Компонент `AlbumPanel`: превью обложки, название, счётчики, переходы к участникам/настройкам/ссылкам; управление обложкой только для owner/admin. Проверка: viewer не видит управление обложкой. +- [x] 4.2 Модальный `CoverPicker` (сетка миниатюр) + `PATCH /albums/:id` с `cover_photo_id`/`clear_cover`. Проверка: назначение и сброс обложки отражаются в рейле, карточке и панели. +- [x] 4.3 Действие «Сделать обложкой» в тулбаре просмотра (роль-gated). Проверка: из просмотра текущее фото становится обложкой альбома. + +## 5. Центр: сетка, плотность, загрузка + +- [x] 5.1 Сетка альбомов edge-to-edge с реальными обложками вместо карточек-заглушек. Проверка: карточки показывают `cover_thumb_url`, при отсутствии — плейсхолдер. +- [x] 5.2 Регулятор плотности миниатюр в тулбаре сетки с сохранением в `localStorage`. Проверка: размер меняется и восстанавливается после перезагрузки. +- [x] 5.3 Загрузка: иконочная кнопка в тулбаре, полноэкранный drag-overlay, зона-подсказка в пустом альбоме; прогресс компактной полосой. Проверка: перетаскивание и выбор файлов загружают фото, пустой альбом показывает подсказку. + +## 6. Просмотр как маршрут + +- [x] 6.1 Маршрут `albums/[id]/photos/[photoId]` и компонент `Viewer` (крупное фото, filmstrip, панель метаданных/комментариев, тулбар, клавиши ←/→/Esc). Проверка: переход из сетки открывает маршрут, адрес отражает фото, навигация синхронна с filmstrip. +- [x] 6.2 Переиспользовать `Viewer` на `share/[token]` вместо инлайн-лайтбокса. Проверка: публичный просмотр использует тот же компонент без рейла/панели. + +## 7. Интеграция + +- [x] 7.1 Пройти сценарий: создать альбом → загрузить фото → задать обложку → открыть просмотр → открыть share-ссылку; проверить поведение на десктопе и узком экране. +- [x] 7.2 Проверить `make build` (сборка фронтенда в `backend/static`, статический Go-бинарник) и `openspec validate "lightroom-workspace-ui"`. From ba605402ceca426b9b3fbc8aa527cd401561eb3f Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 18:28:56 +0300 Subject: [PATCH 02/18] feat(frontend): fullscreen toggle and album back button - add fullscreen/fullscreen_exit icons and a top-bar toggle (right of themes) - add back arrow left of the album title to return to the albums overview --- frontend/src/lib/components/AppShell.svelte | 26 +++++++++++++++++++ frontend/src/lib/icons/registry.ts | 4 ++- .../src/routes/(app)/albums/[id]/+page.svelte | 12 ++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 795d5a6..9e27984 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -24,13 +24,29 @@ let railWidth = $state(RAIL.def); let panelWidth = $state(PANEL.def); + let isFullscreen = $state(false); + onMount(() => { const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10); if (r >= RAIL.min && r <= RAIL.max) railWidth = r; const p = parseInt(localStorage.getItem(PANEL.key) ?? '', 10); if (p >= PANEL.min && p <= PANEL.max) panelWidth = p; + + const onFs = () => (isFullscreen = !!document.fullscreenElement); + document.addEventListener('fullscreenchange', onFs); + onFs(); + return () => document.removeEventListener('fullscreenchange', onFs); }); + async function toggleFullscreen() { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + else await document.documentElement.requestFullscreen(); + } catch { + /* недоступно */ + } + } + function startResize(side: 'left' | 'right', ev: PointerEvent) { ev.preventDefault(); const cfg = side === 'left' ? RAIL : PANEL; @@ -134,6 +150,16 @@ {/if} +
diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 1b2e84e..035c0fb 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -43,7 +43,9 @@ export const icons = { check: { path: '' }, menu: { path: '' }, grid_view: { path: '' }, - wallpaper: { path: '' } + wallpaper: { path: '' }, + fullscreen: { path: '' }, + fullscreen_exit: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte index 8d1d3c5..4b4d884 100644 --- a/frontend/src/routes/(app)/albums/[id]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -155,7 +155,17 @@
-

{album?.title ?? ''}

+
+ + + +

{album?.title ?? ''}

+
{#if canUpload} From 1f0e909ffdbfe010da9d47e12217ab3650ea0082 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 18:36:32 +0300 Subject: [PATCH 03/18] feat(frontend): darken neutral theme and reorder theme switcher - make neutral theme a warm dark graphite - order switches as dark -> neutral -> light -> system - replace neutral icon with contrast glyph --- frontend/src/app.css | 28 +++++++++---------- .../src/lib/components/ThemeSwitcher.svelte | 4 +-- frontend/src/lib/icons/registry.ts | 3 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 6a72e6b..af7b5b3 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -38,22 +38,22 @@ --shadow: 0 0% 0% / 0.4; } -/* Нейтральная (тёплая серо-бежевая) */ +/* Нейтральная (тёплый графит) */ [data-theme='neutral'] { - --bg: 40 20% 96%; - --bg-elevated: 40 22% 92%; - --bg-muted: 40 18% 87%; - --border: 40 14% 78%; - --text: 30 10% 15%; - --text-muted: 30 8% 40%; - --text-subtle: 30 6% 55%; - --primary: 25 90% 50%; + --bg: 30 12% 18%; + --bg-elevated: 30 12% 23%; + --bg-muted: 30 11% 28%; + --border: 30 9% 34%; + --text: 35 18% 93%; + --text-muted: 32 10% 68%; + --text-subtle: 30 7% 50%; + --primary: 25 90% 55%; --primary-fg: 0 0% 100%; - --danger: 0 72% 55%; - --success: 142 55% 42%; - --chrome: 40 22% 92%; - --canvas: 40 20% 96%; - --shadow: 30 20% 20% / 0.12; + --danger: 0 72% 62%; + --success: 142 45% 52%; + --chrome: 30 12% 15%; + --canvas: 30 12% 18%; + --shadow: 30 30% 5% / 0.45; } html, diff --git a/frontend/src/lib/components/ThemeSwitcher.svelte b/frontend/src/lib/components/ThemeSwitcher.svelte index a2dfa2e..8ba896e 100644 --- a/frontend/src/lib/components/ThemeSwitcher.svelte +++ b/frontend/src/lib/components/ThemeSwitcher.svelte @@ -4,9 +4,9 @@ import { theme, type Theme } from '$lib/stores/theme.svelte'; const options: { value: Theme; label: string; icon: IconName }[] = [ - { value: 'light', label: 'Light', icon: 'light_mode' }, { value: 'dark', label: 'Dark', icon: 'dark_mode' }, - { value: 'neutral', label: 'Neutral', icon: 'palette' }, + { value: 'neutral', label: 'Neutral', icon: 'contrast' }, + { value: 'light', label: 'Light', icon: 'light_mode' }, { value: 'system', label: 'System', icon: 'brightness_auto' } ]; diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 035c0fb..609a58d 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -45,7 +45,8 @@ export const icons = { grid_view: { path: '' }, wallpaper: { path: '' }, fullscreen: { path: '' }, - fullscreen_exit: { path: '' } + fullscreen_exit: { path: '' }, + contrast: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; From aa396bdb8561564150e4733e4763f80caf108bc6 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 18:50:16 +0300 Subject: [PATCH 04/18] feat: photo rename and viewer delete with confirmation Backend: - add PATCH /api/photos/:id to rename original_filename (uploader/owner/admin) - emit photo.updated SSE event Frontend: - inline filename editor in the right panel - viewer: back button (arrow_back) on the left, delete on the right - delete confirmed via a modal dialog; filmstrip updates after deletion --- backend/internal/app/app.go | 1 + backend/internal/photos/handlers.go | 18 ++++ backend/internal/photos/service.go | 34 +++++++ backend/internal/photos/store.go | 11 +++ frontend/src/lib/components/AlbumPanel.svelte | 92 +++++++++++++++++-- .../src/lib/components/ConfirmDialog.svelte | 62 +++++++++++++ frontend/src/lib/components/Viewer.svelte | 36 +++++++- .../src/routes/(app)/albums/[id]/+page.svelte | 5 + .../albums/[id]/photos/[photoId]/+page.svelte | 50 ++++++++++ 9 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/components/ConfirmDialog.svelte diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7330727..0107107 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -133,6 +133,7 @@ func (s *Server) Router() *gin.Engine { authed.GET("/albums/:id/photos", photosHandler.List) authed.POST("/albums/:id/photos", photosHandler.Upload) authed.GET("/photos/:id", photosHandler.Get) + authed.PATCH("/photos/:id", photosHandler.Rename) authed.DELETE("/photos/:id", photosHandler.Delete) authed.GET("/photos/:id/download", photosHandler.Download) diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 4d0190c..671bfbe 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -93,6 +93,24 @@ func (h *Handler) Get(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"photo": p}) } +type renameRequest struct { + OriginalFilename string `json:"original_filename" binding:"required"` +} + +func (h *Handler) Rename(c *gin.Context) { + var req renameRequest + if err := c.ShouldBindJSON(&req); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_request", "original_filename is required")) + return + } + p, err := h.svc.Rename(c.Request.Context(), c.Param("id"), auth.UserID(c), req.OriginalFilename) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"photo": p}) +} + func (h *Handler) Download(c *gin.Context) { url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c)) if err != nil { diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 9f3482d..425e22c 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -250,6 +250,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string, return url, nil } +func (s *Service) Rename(ctx context.Context, photoID, userID, filename 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 + } + own := p.UploaderID == userID + if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) { + return nil, httpx.Forbidden("forbidden", "you cannot rename this photo") + } + name := strings.TrimSpace(filename) + if name == "" { + return nil, httpx.BadRequest("invalid_filename", "filename is required") + } + if len(name) > 255 { + name = name[:255] + } + if err := s.store.UpdateFilename(ctx, photoID, name); err != nil { + return nil, err + } + p.OriginalFilename = name + if err := s.attachURLs(ctx, p); err != nil { + return nil, err + } + s.hub.Publish(p.AlbumID, "photo.updated", p) + return p, nil +} + func (s *Service) Delete(ctx context.Context, photoID, userID string) error { p, err := s.store.GetByID(ctx, photoID, userID) if err != nil { diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 848e630..397b5db 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -127,3 +127,14 @@ func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) { `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, albumID, photoID) } + +func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error { + tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index 40f51e2..976c46b 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -5,6 +5,7 @@ import CoverPicker from './CoverPicker.svelte'; import Comments from './Comments.svelte'; import { albums } from '$lib/stores/albums.svelte'; + import { auth } from '$lib/stores/auth.svelte'; import type { Album, Photo } from '$lib/types'; const albumId = $derived($page.params.id ?? ''); @@ -16,17 +17,20 @@ let picking = $state(false); let photo = $state(null); + let editingName = $state(false); + let nameDraft = $state(''); + let savingName = $state(false); + let nameError = $state(''); const canEdit = $derived(role === 'owner' || role === 'admin'); + const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id)); $effect(() => { const id = albumId; error = ''; - if (!id || photoId) { - if (!id) { - album = null; - role = ''; - } + if (!id) { + album = null; + role = ''; return; } api<{ album: Album; role: string }>(`/albums/${id}`) @@ -40,6 +44,8 @@ $effect(() => { const id = photoId; + editingName = false; + nameError = ''; if (!id) { photo = null; return; @@ -49,6 +55,37 @@ .catch((e) => (error = (e as Error).message)); }); + function startEdit() { + if (!photo) return; + nameDraft = photo.original_filename; + nameError = ''; + editingName = true; + } + + async function saveName(ev: Event) { + ev.preventDefault(); + if (!photo || !nameDraft.trim()) return; + savingName = true; + nameError = ''; + try { + const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, { + method: 'PATCH', + body: { original_filename: nameDraft.trim() } + }); + photo = data.photo; + editingName = false; + } catch (e) { + nameError = (e as Error).message; + } finally { + savingName = false; + } + } + + function autofocus(node: HTMLInputElement) { + node.focus(); + node.select(); + } + async function resetCover() { if (!album) return; try { @@ -66,9 +103,48 @@ {#if photoId} {#if photo}
-

- {photo.original_filename} -

+ {#if editingName} +
+ +
+ + +
+
+ {#if nameError}

{nameError}

{/if} + {:else if canRename} + + {:else} +

{photo.original_filename}

+ {/if}
Автор{photo.uploader.name}
{#if photo.taken_at} diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..862640f --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index d9b1d41..e4dca15 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -7,12 +7,23 @@ photos: Photo[]; index: number; canEdit?: boolean; + canDelete?: boolean; onClose: () => void; onIndexChange: (index: number) => void; onMakeCover?: (photo: Photo) => void; + onDelete?: (photo: Photo) => void; } - let { photos, index, canEdit = false, onClose, onIndexChange, onMakeCover }: Props = $props(); + let { + photos, + index, + canEdit = false, + canDelete = false, + onClose, + onIndexChange, + onMakeCover, + onDelete + }: Props = $props(); let fresh = $state(null); let touchX = 0; @@ -52,13 +63,20 @@ 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';
- {index + 1} / {photos.length} +
+ + {index + 1} / {photos.length} +
{#if canEdit} + {#if canDelete} + + {/if}
diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte index 4b4d884..1cba6ae 100644 --- a/frontend/src/routes/(app)/albums/[id]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -90,6 +90,11 @@ photos = photos.filter((p) => p.id !== data.id); break; } + case 'photo.updated': { + const photo = data as unknown as Photo; + photos = photos.map((p) => (p.id === photo.id ? photo : p)); + break; + } case 'like.added': { photos = photos.map((p) => p.id === data.photo_id diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte index ba1dd48..b74a798 100644 --- a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte @@ -3,7 +3,9 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import Viewer from '$lib/components/Viewer.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import { albums } from '$lib/stores/albums.svelte'; + import { auth } from '$lib/stores/auth.svelte'; import type { Album, Photo } from '$lib/types'; const albumId = $derived($page.params.id ?? ''); @@ -14,9 +16,15 @@ let loading = $state(true); let error = $state(''); let saved = $state(false); + let pendingDelete = $state(null); + let deleting = $state(false); const canEdit = $derived(role === 'owner' || role === 'admin'); const index = $derived(photos.findIndex((p) => p.id === photoId)); + const currentPhoto = $derived(index >= 0 ? photos[index] : null); + const canDelete = $derived( + canEdit || (!!currentPhoto && currentPhoto.uploader_id === auth.user?.id) + ); $effect(() => { const id = albumId; @@ -53,6 +61,36 @@ error = (e as Error).message; } } + + async function removePhoto(photo: Photo) { + pendingDelete = photo; + } + + async function confirmDelete() { + const photo = pendingDelete; + if (!photo) return; + deleting = true; + error = ''; + try { + await api(`/photos/${photo.id}`, { method: 'DELETE' }); + pendingDelete = null; + const remaining = photos.filter((p) => p.id !== photo.id); + if (remaining.length > 0) { + const next = remaining[Math.min(Math.max(index, 0), remaining.length - 1)]; + await goto(`/albums/${albumId}/photos/${next.id}`, { + replaceState: true, + noScroll: true + }); + } else { + await goto(`/albums/${albumId}`); + } + photos = remaining; + } catch (e) { + error = (e as Error).message; + } finally { + deleting = false; + } + } {#if loading} @@ -74,10 +112,22 @@ {photos} {index} {canEdit} + {canDelete} onClose={close} onIndexChange={change} onMakeCover={makeCover} + onDelete={removePhoto} />
+ + {#if pendingDelete} + (pendingDelete = null)} + /> + {/if} {/if} From b901544dc59b0660910d7660282df3e2e0edcbbc Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 19:15:39 +0300 Subject: [PATCH 05/18] feat(frontend): collapsible right-panel sections with photo info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add PanelSection with persisted collapse state - restructure album and photo panels into sections - add "Информация" section with EXIF/metadata (size, type, resolution, taken, coords) --- frontend/src/lib/components/AlbumPanel.svelte | 191 ++++++++++-------- .../src/lib/components/PanelSection.svelte | 46 +++++ 2 files changed, 155 insertions(+), 82 deletions(-) create mode 100644 frontend/src/lib/components/PanelSection.svelte diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index 976c46b..e5ae80e 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -4,6 +4,7 @@ import Icon from './Icon.svelte'; import CoverPicker from './CoverPicker.svelte'; import Comments from './Comments.svelte'; + import PanelSection from './PanelSection.svelte'; import { albums } from '$lib/stores/albums.svelte'; import { auth } from '$lib/stores/auth.svelte'; import type { Album, Photo } from '$lib/types'; @@ -86,6 +87,18 @@ 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 { @@ -102,76 +115,88 @@ {#if photoId} {#if photo} -
- {#if editingName} -
- -
- - -
-
- {#if nameError}

{nameError}

{/if} - {:else if canRename} - - {:else} -

{photo.original_filename}

- {/if} -
-
Автор{photo.uploader.name}
- {#if photo.taken_at} -
- Снято - {new Date(photo.taken_at).toLocaleString()} -
+
+
+ {#if editingName} +
+ +
+ + +
+
+ {#if nameError}

{nameError}

{/if} + {:else if canRename} + + {:else} +

{photo.original_filename}

{/if} -
- Размер - {photo.width}×{photo.height} -
-
- - Комментарии - + + +
+
Автор{photo.uploader.name}
+
Файл{formatSize(photo.size)}
+
Тип{photo.mime_type}
+
+ Разрешение + {photo.width}×{photo.height} +
+ {#if photo.taken_at} +
+ Снято + {new Date(photo.taken_at).toLocaleString()} +
+ {/if} + {#if photo.latitude != null && photo.longitude != null} +
+ Координаты + {photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)} +
+ {/if} +
+ Загружено + {new Date(photo.created_at).toLocaleString()} +
+
+
+ + -
+
{/if} {:else if album} -
-
- - Обложка - +
+
{#if album.cover_thumb_url} @@ -202,33 +227,35 @@ {/if}
{/if} -
+ -
+

{album.title}

{#if album.description}

{album.description}

{/if}

{album.photos_count ?? 0} фото · {album.members_count ?? 0} участников

{#if error}

{error}

{/if} -
+ - +
{#if picking} diff --git a/frontend/src/lib/components/PanelSection.svelte b/frontend/src/lib/components/PanelSection.svelte new file mode 100644 index 0000000..9c20767 --- /dev/null +++ b/frontend/src/lib/components/PanelSection.svelte @@ -0,0 +1,46 @@ + + +
+ + {#if isOpen} +
{@render children()}
+ {/if} +
From 3886cc6511e5183b9fa79cb5668dc85b65bb90f6 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 19:15:43 +0300 Subject: [PATCH 06/18] feat(frontend): photo viewer fullscreen and zoom - fullscreen toggle button + "f" hotkey (both layouts) - wheel zoom anchored to cursor; Space toggles 100% (1:1) - pan when zoomed via drag/scrollbars; reset zoom on photo change - hotkeys ignored while typing in inputs --- frontend/src/lib/components/Viewer.svelte | 219 +++++++++++++++++++--- 1 file changed, 193 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index e4dca15..5b6d7b4 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -1,4 +1,5 @@ - + -
-
+
+
-
+
{/if} @@ -302,20 +450,17 @@
-
+
{#each photos as photo, i (photo.id)}
+ {#each exifRows as row (row.key)} +
+ {row.label} + {row.value} +
+ {/each} {#if photo.taken_at}
Снято diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 7e35a10..2bef15f 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -7,24 +7,13 @@ interface Props { photos: Photo[]; index: number; - canEdit?: boolean; canDelete?: boolean; onClose: () => void; onIndexChange: (index: number) => void; - onMakeCover?: (photo: Photo) => void; onDelete?: (photo: Photo) => void; } - let { - photos, - index, - canEdit = false, - canDelete = false, - onClose, - onIndexChange, - onMakeCover, - onDelete - }: Props = $props(); + let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props(); let fresh = $state(null); let touchX = 0; @@ -398,21 +387,16 @@ > - {#if canEdit} - - {/if} {#if fresh?.download_url} - + - {/if} {#if canDelete} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1fe6b33..3482dc4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -39,6 +39,7 @@ export interface Photo { latitude?: number | null; longitude?: number | null; caption: string; + exif?: Record; created_at: string; uploader: User; likes_count: number; diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte index b74a798..4473b28 100644 --- a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte @@ -4,7 +4,6 @@ import { api } from '$lib/api'; import Viewer from '$lib/components/Viewer.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; - import { albums } from '$lib/stores/albums.svelte'; import { auth } from '$lib/stores/auth.svelte'; import type { Album, Photo } from '$lib/types'; @@ -15,7 +14,6 @@ let role = $state(''); let loading = $state(true); let error = $state(''); - let saved = $state(false); let pendingDelete = $state(null); let deleting = $state(false); @@ -51,17 +49,6 @@ if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true }); } - async function makeCover(photo: Photo) { - if (!canEdit) return; - try { - await albums.setCover(albumId, photo.id); - saved = true; - setTimeout(() => (saved = false), 2000); - } catch (e) { - error = (e as Error).message; - } - } - async function removePhoto(photo: Photo) { pendingDelete = photo; } @@ -104,18 +91,13 @@
{:else}
- {#if saved} -

Обложка обновлена

- {/if}
diff --git a/openspec/changes/lightroom-workspace-ui/design.md b/openspec/changes/lightroom-workspace-ui/design.md index bcd5540..cbb931e 100644 --- a/openspec/changes/lightroom-workspace-ui/design.md +++ b/openspec/changes/lightroom-workspace-ui/design.md @@ -24,12 +24,15 @@ - Тёмная графитовая тема по умолчанию при сохранении остальных тем. - Просмотр фото как маршрут в каркасе, общий с публичной share-страницей. - Адаптивность до мобильного без потери каркаса. +- Пер-пользовательские настройки раздела «Информация» (видимость атрибутов и подписи), + хранимые в профиле. **Non-Goals:** - Мультивыбор фото и массовые действия / bulk-удаление. - Замена роутинга на «чистые панели» без маршрутов (вариант P). - Новые внешние зависимости (иконки и токены вендорятся/расширяются). - Изменение доменных контрактов API (только добавление поля ответа и FK). +- Порядок атрибутов и настройки на уровне альбома/фото — настройки глобальны для пользователя. ## Decisions @@ -59,10 +62,10 @@ ON DELETE SET NULL`. БД сама чинит любой путь удалени безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже. Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления). -### 5. Точки выбора обложки: панель + просмотр (T1+T2) +### 5. Точка выбора обложки: правая панель (T1) Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`; -в просмотре — действие «Сделать обложкой» для текущего фото. Всё под `roles.CanEditAlbum`. -Hover-оверлей в сетке (T3) не берём — шум и случайные клики. +всё под `roles.CanEditAlbum`. Действие «Сделать обложкой» в тулбаре просмотра и hover-оверлей +в сетке (T3) не используются — шум и случайные клики. ### 6. Тема: сохранить темы, dark = графит и дефолт (α) Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию @@ -101,11 +104,16 @@ filmstrip, панель метаданных/комментариев, тулб ### 13. Просмотр: полный экран и зум Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по -`fullscreenchange`. Зум — локальные `zoomed`/`scale`: колесо меняет масштаб с привязкой к -курсору; `Space` переключает 100% (1:1); при увеличении изображение панорамируется -перетаскиванием и скроллбарами. Хоткеи игнорируются при фокусе в поле ввода. Зум -сбрасывается при смене фото. Альтернатива — только `object-fit` без ручного масштаба — -не даёт 1:1 и панорамирования. +`fullscreenchange`. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото +скрыты и всплывают у соответствующих кромок (панели — по касанию края, скрытие ~1 с после +ухода; стрелки — при курсоре в 1/6 левой/правой области, скрытие ~300 мс; в зуме стрелки +скрыты полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото: +колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%; +`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум +догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование — +перетаскиванием и скроллом. Хоткеи игнорируются при фокусе в поле ввода; зум сбрасывается при +смене фото. Альтернатива — только `object-fit` без ручного масштаба — не даёт 1:1 и +панорамирования. ### 14. Панели: ресайз и сворачиваемые разделы Ширина рейла и панели хранится в `localStorage`, меняется перетаскиванием разделителя @@ -124,6 +132,33 @@ filmstrip, панель метаданных/комментариев, тулб панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома обновляет запись в списке. +### 17. Настройки информации: оверрайды в профиле пользователя +Настройки раздела «Информация» хранятся в профиле как оверрайды, а не как полный список: +`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида +`{ "hidden": ["Contrast", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие ключа +означает, что атрибут видим и подпись дефолтная, поэтому новые и незнакомые теги EXIF не +«теряются» и не требуют миграций данных. `GET /auth/me` отдаёт `preferences` вместе с +пользователем; сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной +валидацией: строки и массивы строк, ограничение длины подписи/ключа и числа скрытых ключей). +Настройки не входят в `PublicUser` (приватны). UI: `PanelSection` получает слот `actions` под +шестерёнку; модалка `PhotoInfoSettingsDialog` строится из объединения известного каталога +подписей и ключей текущего фото; список в панели фильтруется по оверрайдам, а скрытые +атрибуты сворачиваются под кнопку «…» (раскрытие — временное, для текущего фото). +Альтернативы: полный упорядоченный список в профиле (хрупок к новым тегам, нужны миграции) и +хранение на клиенте (не переносится между устройствами) — отклонены. + +### 18. Метаданные фото: устойчивый парсер и хранение в jsonb +EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`) +как плоская карта «тег → значение» всех распознаваемых библиотекой тегов. Парсер — +`github.com/dsoprea/go-exif/v3`: он переживает нестандартные и битые блоки EXIF, на которых +падал прежний `rwcarlsen/goexif` (реальные снимки HUAWEI давали zero-length sub-IFD и пустой +результат). Числовые значения нормализуются (дроби → число); дата съёмки и GPS дополнительно +пишутся в колонки `taken_at`, `latitude`/`longitude`. На клиенте теги показываются с русскими +подписями и переводом enum. Для ранее загруженных фото предусмотрена разовая команда +`cmd/backfill-exif`, которая тянет оригиналы из S3 (`storage.Get`) и заполняет +`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена: +пользователю нужны все атрибуты. + ## Risks / Trade-offs - [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети; @@ -147,19 +182,29 @@ filmstrip, панель метаданных/комментариев, тулб - [Крупные файлы утяжеляют память] → лимит 100 МБ и чтение через `LimitReader`; превышение даёт понятную 413. - [Сохранённые ширина/состояние панелей] → значения ограничиваются допустимым диапазоном. +- [Настройки в профиле: рост jsonb и приватность] → `preferences` не входят в `PublicUser`; + серверная валидация ограничивает структуру, длину подписей и число скрытых ключей. ## Migration Plan 1. Миграция `000002_album_cover_fk`: `UPDATE albums SET cover_photo_id = NULL WHERE cover_photo_id IS NOT NULL AND cover_photo_id NOT IN (SELECT id FROM photos);` затем `ADD CONSTRAINT ... ON DELETE SET NULL`. Down — `DROP CONSTRAINT`. -2. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go` +2. Миграция `000003_photo_exif`: `ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT + NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`. +3. Миграция `000004_user_preferences`: `ALTER TABLE users ADD COLUMN preferences jsonb NOT + NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`. +4. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go` (LEFT JOIN + счётчики), `albums/service.go` (enrich + presign), `photos` (автообложка только - если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`). -3. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с - разделами и EXIF, модалки, `Viewer` с зумом), стор альбомов, токены и скроллбары, `types.ts`. -4. Откат: снять constraint (down-миграция); фронтенд откатывается независимо, поле ответа - игнорируется старым клиентом. + если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`; извлечение EXIF в + `photos.exif` и команда `cmd/backfill-exif`), `models.User` (+`Preferences`), `auth` + (`GetUserByID` c `preferences`, `Me` отдаёт настройки, `PATCH /auth/me/preferences` с + валидацией). +5. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с + разделами и EXIF, модалки, `Viewer` с зумом), слот действий и модалка настроек атрибутов, + стор настроек, `types.ts`. +6. Откат: снять constraint и колонки `photos.exif`/`users.preferences` (down-миграции); + фронтенд откатывается независимо, поля ответа игнорируются старым клиентом. ## Open Questions diff --git a/openspec/changes/lightroom-workspace-ui/proposal.md b/openspec/changes/lightroom-workspace-ui/proposal.md index 9295f84..6244448 100644 --- a/openspec/changes/lightroom-workspace-ui/proposal.md +++ b/openspec/changes/lightroom-workspace-ui/proposal.md @@ -18,8 +18,8 @@ - **Создание альбома.** Инлайн-форма заменяется иконочной кнопкой с хинтом, открывающей модалку ввода названия. - **Обложки.** `Album` получает `cover_thumb_url`; карточки и рейл показывают реальную - миниатюру. Выбор обложки: пикер в правой панели и действие «сделать обложкой» в - просмотре. Автообложка пишется только когда обложка ещё не задана; FK + миниатюру. Выбор обложки: пикер в правой панели (действие «сделать обложкой» в + просмотре не используется). Автообложка пишется только когда обложка ещё не задана; FK `albums.cover_photo_id -> photos(id) ON DELETE SET NULL` чинит висячие ссылки. - **Просмотр как маршрут.** `Lightbox`-оверлей заменяется маршрутом `/albums/:id/photos/:photoId` внутри шелла: крупное фото, filmstrip снизу, панель @@ -31,10 +31,20 @@ ошибкой при превышении. - **Переименование фото.** `PATCH /photos/:id` меняет `original_filename`; инлайн-редактор в правой панели, доступный автору фото и owner/admin. -- **Просмотр.** Полноэкранный режим (кнопка и хоткей `f`), зум колесом мыши, `Space` — 100% - с панорамированием; удаление текущего фото с модальным подтверждением; возврат к обзору. +- **Метаданные фото (EXIF).** При загрузке система извлекает все распознаваемые EXIF-атрибуты + (камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`; + они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных + фото есть команда бэкфилла метаданных из оригиналов. +- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и + стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space` + возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения; + удаление текущего фото с модальным подтверждением; возврат к обзору. - **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием. +- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую + модалку со списком EXIF-атрибутов: переключатели видимости и пользовательские подписи. + Скрытые атрибуты сворачиваются под элементом «…» (клик раскрывает все). Настройки хранятся + в профиле пользователя (server-side) и применяются ко всем фото. - **Счётчики.** В ответе альбома — число фото и участников. - **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт, `neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая → @@ -53,7 +63,8 @@ - `albums`: отображение обложки, счётчики фото/участников и явный выбор обложки. - `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера - загрузки. + загрузки; извлечение и хранение EXIF-метаданных. +- `auth`: хранение пользовательских настроек отображения информации в профиле. ## Impact @@ -63,14 +74,20 @@ (presign → `CoverThumbURL`, обогащение списка); `internal/models` (`Album.CoverThumbURL`, `PhotosCount`, `MembersCount`); `internal/photos` (автообложка только при `cover_photo_id IS NULL`; `PATCH /photos/:id` для переименования; лимит загрузки 100 МБ с - `413 file_too_large`; событие `photo.updated`). + `413 file_too_large`; событие `photo.updated`; извлечение EXIF через + `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` с валидацией). - **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell` с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения, `Viewer` с полноэкранным режимом и зумом, общий стор альбомов, контрол плотности; новый - маршрут `albums/[id]/photos/[photoId]`; новые значения токенов темы и стили скроллбаров; - `types.ts` (+`cover_thumb_url`, счётчики, `photos_count`); новые иконки в реестре. -- **Спеки:** `ui-workspace` (новая), `albums`, `photos` (дельты). `ui-iconography` не + маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек + атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели; + новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики, + `photos_count`, `preferences`); новые иконки в реестре. +- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не затрагивается — иконки по-прежнему наследуют цвет темы. - **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих изменений API нет. diff --git a/openspec/changes/lightroom-workspace-ui/specs/auth/spec.md b/openspec/changes/lightroom-workspace-ui/specs/auth/spec.md new file mode 100644 index 0000000..fa14247 --- /dev/null +++ b/openspec/changes/lightroom-workspace-ui/specs/auth/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### 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/photos/spec.md b/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md index ab25c72..0f84edc 100644 --- a/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md +++ b/openspec/changes/lightroom-workspace-ui/specs/photos/spec.md @@ -41,3 +41,15 @@ #### Scenario: Файл в пределах лимита - **WHEN** файл не превышает лимит - **THEN** система принимает multipart-загрузку и обрабатывает фото + +### Requirement: Метаданные фото (EXIF) +При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их +вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор. + +#### Scenario: Фото с метаданными +- **WHEN** загружается фото с EXIF +- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.) + +#### Scenario: Фото без метаданных +- **WHEN** загружается фото без читаемых EXIF +- **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 4d00240..2b5841f 100644 --- a/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md +++ b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md @@ -139,7 +139,8 @@ SHALL отображаться вне каркаса, но в общей тем ### Requirement: Сворачиваемые разделы панели Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние -сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные EXIF фото. +сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото, +включая извлечённые EXIF-атрибуты. #### Scenario: Сворачивание раздела - **WHEN** пользователь нажимает заголовок раздела @@ -147,30 +148,47 @@ SHALL отображаться вне каркаса, но в общей тем #### Scenario: Метаданные фото - **WHEN** открыт просмотр фото -- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки и координаты +- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки, координаты и извлечённые EXIF-атрибуты ### Requirement: Полноэкранный просмотр фотографии В режиме просмотра SHALL быть доступен переход в полноэкранный режим кнопкой и хоткеем `f`. +В полноэкранном режиме верхняя и нижняя панели SHALL скрываться и показываться у +соответствующей кромки экрана. #### Scenario: Вход в полный экран - **WHEN** пользователь нажимает кнопку полного экрана или клавишу `f` -- **THEN** просмотр разворачивается на весь экран +- **THEN** просмотр разворачивается на весь экран, панели скрыты + +#### Scenario: Панель по кромке +- **WHEN** курсор касается верхней или нижней кромки экрана +- **THEN** соответствующая панель показывается и скрывается примерно через секунду после ухода курсора #### Scenario: Выход из полного экрана - **WHEN** пользователь повторно нажимает кнопку или клавишу `f` - **THEN** полноэкранный режим закрывается ### Requirement: Зум в просмотре -Просмотр SHALL поддерживать зум: колесо мыши меняет масштаб, `Space` переключает 100%, а при -увеличении фото SHALL панорамироваться. Зум SHALL работать и в полноэкранном режиме. +Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и +верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во +вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения. +Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL +работать и в полноэкранном режиме. -#### Scenario: Зум колесом +#### Scenario: Зум колесом к указателю - **WHEN** пользователь вращает колесо мыши над фото -- **THEN** масштаб фото меняется относительно положения курсора +- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200% -#### Scenario: 100% по пробелу +#### Scenario: Пробел - **WHEN** пользователь нажимает `Space` -- **THEN** фото показывается в масштабе 100% (1:1), повторное нажатие возвращает вписанный вид +- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный + +#### Scenario: Оригинал в зуме +- **WHEN** пользователь входит в зум +- **THEN** догружается оригинал изображения и подменяется по факту загрузки + +#### Scenario: Стрелки соседних фото +- **WHEN** курсор находится у левой или правой кромки области просмотра +- **THEN** показывается соответствующая стрелка, скрываясь примерно через 300 мс после ухода; в зуме стрелки скрыты #### Scenario: Панорамирование - **WHEN** фото увеличено @@ -198,3 +216,38 @@ SHALL отображаться вне каркаса, но в общей тем #### Scenario: Возврат из просмотра - **WHEN** пользователь нажимает кнопку «назад» в режиме просмотра - **THEN** открывается альбом с сеткой фото + +### Requirement: Настройки списка атрибутов информации +Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим +модальное окно, в котором пользователь SHALL выбирать отображаемые EXIF-атрибуты и задавать +их подписи. Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле +пользователя. При отсутствии настроек SHALL отображаться все доступные атрибуты. + +#### Scenario: Открытие настроек +- **WHEN** пользователь нажимает иконку настроек в разделе «Информация» +- **THEN** открывается модальное окно со списком EXIF-атрибутов: переключатели видимости и поля подписей + +#### Scenario: Сохранение настроек +- **WHEN** пользователь скрывает атрибут, задаёт подпись и сохраняет +- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле + +#### Scenario: Сброс настроек +- **WHEN** пользователь сбрасывает настройки к значениям по умолчанию +- **THEN** показываются все атрибуты с исходными подписями + +### Requirement: Свёртка скрытых атрибутов +Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент +«…»; нажатие на него SHALL раскрывать все атрибуты текущего фото, включая скрытые. Если +скрытых атрибутов нет, элемент «…» SHALL не отображаться. + +#### Scenario: Скрытые атрибуты +- **WHEN** в разделе «Информация» есть скрытые настройками атрибуты +- **THEN** после видимых атрибутов показан элемент «…» + +#### Scenario: Раскрытие всех атрибутов +- **WHEN** пользователь нажимает «…» +- **THEN** отображаются все атрибуты фото, включая скрытые + +#### Scenario: Нет скрытых атрибутов +- **WHEN** ни один атрибут не скрыт +- **THEN** элемент «…» не отображается diff --git a/openspec/changes/lightroom-workspace-ui/tasks.md b/openspec/changes/lightroom-workspace-ui/tasks.md index fa2dcf3..21da5ff 100644 --- a/openspec/changes/lightroom-workspace-ui/tasks.md +++ b/openspec/changes/lightroom-workspace-ui/tasks.md @@ -25,7 +25,7 @@ - [x] 4.1 Компонент `AlbumPanel`: превью обложки, название, счётчики, переходы к участникам/настройкам/ссылкам; управление обложкой только для owner/admin. Проверка: viewer не видит управление обложкой. - [x] 4.2 Модальный `CoverPicker` (сетка миниатюр) + `PATCH /albums/:id` с `cover_photo_id`/`clear_cover`. Проверка: назначение и сброс обложки отражаются в рейле, карточке и панели. -- [x] 4.3 Действие «Сделать обложкой» в тулбаре просмотра (роль-gated). Проверка: из просмотра текущее фото становится обложкой альбома. +- [x] 4.3 Выбор обложки выполняется только из правой панели (модальный пикер); действие «Сделать обложкой» в тулбаре просмотра не используется. Проверка: обложка задаётся и сбрасывается из панели, в тулбаре просмотра кнопки нет. ## 5. Центр: сетка, плотность, загрузка @@ -47,9 +47,22 @@ - [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит. - [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото. -- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) и зум (колесо, `Space` 100%, панорамирование) в обычном и полноэкранном режиме. Проверка: хоткеи и зум работают, при вводе текста не срабатывают. +- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, скрытие ~300 мс, в зуме скрыты); зум колесом к указателю без прокрутки (макс 200%), `Space` возвращает из любого зума во вписанный вид, в зуме подгружается оригинал. Проверка: поведение подтверждено в браузере (автоскрытие, зум к указателю, предел 200%, подмена превью→оригинал). - [x] 8.4 Сворачиваемые разделы правой панели с сохранением состояния и раздел «Информация» (EXIF). Проверка: состояние сохраняется между сессиями, метаданные отображаются. - [x] 8.5 Ресайз левой и правой панелей перетаскиванием с сохранением ширины. Проверка: ширина восстанавливается после перезагрузки. - [x] 8.6 Лимит загрузки 100 МБ и понятные ошибки (клиентская проверка и `413 file_too_large`). Проверка: 60 МБ проходит разбор, превышение лимита даёт явную ошибку размера. - [x] 8.7 Счётчики `photos_count`/`members_count` в ответе альбома. Проверка: `GET /api/albums` и `GET /api/albums/:id` содержат счётчики. - [x] 8.8 Тема и хром: нейтральная тема — тёплый графит, порядок и значок переключателя, стилизация скроллбаров. Проверка: темы переключаются согласованно, скроллбары в тон темы. +- [x] 8.9 Извлечение EXIF: парсер заменён на `github.com/dsoprea/go-exif/v3` (все распознаваемые теги, нормализация чисел), хранение в `photos.exif` (миграция `000003_photo_exif`, jsonb `'{}'` по умолчанию), отдача в API фото; дата съёмки и GPS дублируются в `taken_at`/координаты. Проверка: реальное фото HUAWEI nova 15 Pro даёт 67 атрибутов; `go test ./...`. +- [x] 8.10 Бэкфилл метаданных существующих фото: команда `cmd/backfill-exif` + `storage.Get` + `photos.Store.BackfillEXIF` (тянет оригиналы из S3, заполняет `exif`/`taken_at`/координаты). Проверка: прогон обновил 25 фото, `taken_at` проставлен. +- [x] 8.11 Раздел «Информация» показывает все EXIF-атрибуты с русскими подписями, переводом enum и единицами (выдержка `1/N`, `f/N`, `N мм`, EV); служебные/дублирующие теги скрыты. Проверка: Playwright на реальном фото — 9/9. + +## 9. Настройки информации пользователя + +- [ ] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает. +- [ ] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется. +- [ ] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы. +- [ ] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`. +- [ ] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле. +- [ ] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы. +- [ ] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`. From 1b4906648d15a7efab2156ccf75e96f54b99e778 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 21:04:08 +0300 Subject: [PATCH 13/18] feat(preferences): configurable photo info attributes stored in user profile - backend: users.preferences jsonb (migration 000004), Me returns preferences, PATCH /auth/me/preferences with validation (visible list + labels) - frontend: gear in info section opens PhotoInfoSettingsDialog; base attribute set shown by default, remaining under ellipsis; per-attribute visibility and custom labels persisted to the profile; reset clears settings from the profile - frontend: dialog has select-all/deselect-all icon buttons (no 'show all'); EXIF constants extracted to lib/exif.ts - fix: render modal overlays via portal to document.body so they center in the viewport despite transformed panel/rail ancestors - openspec: sync lightroom-workspace-ui artifacts (spec/design/proposal/tasks 9.8) --- backend/internal/app/app.go | 1 + backend/internal/auth/handlers.go | 79 ++++- backend/internal/auth/store.go | 25 +- backend/internal/models/models.go | 13 +- .../000004_user_preferences.down.sql | 1 + .../migrations/000004_user_preferences.up.sql | 1 + frontend/src/lib/actions/portal.ts | 10 + frontend/src/lib/api.ts | 8 +- frontend/src/lib/components/AlbumPanel.svelte | 275 +++++------------- .../src/lib/components/ConfirmDialog.svelte | 2 + .../src/lib/components/CoverPicker.svelte | 2 + .../lib/components/CreateAlbumDialog.svelte | 2 + .../src/lib/components/PanelSection.svelte | 32 +- .../components/PhotoInfoSettingsDialog.svelte | 179 ++++++++++++ frontend/src/lib/exif.ts | 224 ++++++++++++++ frontend/src/lib/icons/registry.ts | 4 +- frontend/src/lib/stores/auth.svelte.ts | 15 + frontend/src/lib/types.ts | 10 + frontend/src/routes/+layout.svelte | 2 +- .../changes/lightroom-workspace-ui/design.md | 29 +- .../lightroom-workspace-ui/proposal.md | 8 +- .../specs/ui-workspace/spec.md | 20 +- .../changes/lightroom-workspace-ui/tasks.md | 15 +- 23 files changed, 703 insertions(+), 254 deletions(-) create mode 100644 backend/migrations/000004_user_preferences.down.sql create mode 100644 backend/migrations/000004_user_preferences.up.sql create mode 100644 frontend/src/lib/actions/portal.ts create mode 100644 frontend/src/lib/components/PhotoInfoSettingsDialog.svelte create mode 100644 frontend/src/lib/exif.ts diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0107107..5bfc986 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine { authed.Use(s.auth.Middleware()) authed.GET("/auth/me", authHandler.Me) + authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences) authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/me", s.meSSE) diff --git a/backend/internal/auth/handlers.go b/backend/internal/auth/handlers.go index 3b0700e..352ec41 100644 --- a/backend/internal/auth/handlers.go +++ b/backend/internal/auth/handlers.go @@ -1,6 +1,9 @@ package auth import ( + "encoding/json" + "fmt" + "io" "net/http" "github.com/gin-gonic/gin" @@ -89,5 +92,79 @@ func (h *Handler) Me(c *gin.Context) { httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found")) return } - c.JSON(http.StatusOK, gin.H{"user": u.Public()}) + prefs := u.Preferences + if prefs == nil { + prefs = map[string]any{} + } + c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs}) +} + +const ( + maxVisibleAttrs = 500 + maxLabelEntries = 500 + maxPreferenceKeyLen = 128 + maxPreferenceValueLen = 128 +) + +type photoInfoPreferences struct { + Visible []string `json:"visible"` + Labels map[string]string `json:"labels,omitempty"` +} + +type preferencesPayload struct { + PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"` +} + +func (p preferencesPayload) validate() error { + pi := p.PhotoInfo + if pi == nil { + return nil + } + if len(pi.Visible) > maxVisibleAttrs { + return fmt.Errorf("too many visible attributes") + } + for _, k := range pi.Visible { + if k == "" || len(k) > maxPreferenceKeyLen { + return fmt.Errorf("invalid visible attribute key") + } + } + if len(pi.Labels) > maxLabelEntries { + return fmt.Errorf("too many label overrides") + } + for k, v := range pi.Labels { + if k == "" || len(k) > maxPreferenceKeyLen { + return fmt.Errorf("invalid label key") + } + if len(v) > maxPreferenceValueLen { + return fmt.Errorf("label value too long") + } + } + return nil +} + +func (h *Handler) UpdatePreferences(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not read request body")) + return + } + var payload preferencesPayload + if err := json.Unmarshal(body, &payload); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object")) + return + } + if err := payload.validate(); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error())) + return + } + raw, err := json.Marshal(payload) + if err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences")) + return + } + if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"preferences": payload}) } diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go index 5b3173d..054375c 100644 --- a/backend/internal/auth/store.go +++ b/backend/internal/auth/store.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/json" "errors" "github.com/jackc/pgx/v5" @@ -29,27 +30,45 @@ func (s *Store) CreateUser(ctx context.Context, u *models.User) error { func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { return s.scanUser(s.pool.QueryRow(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at + `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), + COALESCE(preferences, '{}'::jsonb)::text, created_at FROM users WHERE lower(email) = lower($1)`, email)) } func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { return s.scanUser(s.pool.QueryRow(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at + `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), + COALESCE(preferences, '{}'::jsonb)::text, created_at FROM users WHERE id = $1`, id)) } func (s *Store) scanUser(row pgx.Row) (*models.User, error) { var u models.User - if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil { + var prefs string + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } + if prefs != "" && prefs != "null" { + _ = json.Unmarshal([]byte(prefs), &u.Preferences) + } return &u, nil } +func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error { + tag, err := s.pool.Exec(ctx, + `UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error { _, err := s.pool.Exec(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index b236c6c..0ff50b2 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -3,12 +3,13 @@ package models import "time" type User struct { - ID string `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"-"` - Name string `json:"name"` - AvatarURL string `json:"avatar_url,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url,omitempty"` + Preferences map[string]any `json:"preferences,omitempty"` + CreatedAt time.Time `json:"created_at"` } type PublicUser struct { diff --git a/backend/migrations/000004_user_preferences.down.sql b/backend/migrations/000004_user_preferences.down.sql new file mode 100644 index 0000000..109d772 --- /dev/null +++ b/backend/migrations/000004_user_preferences.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS preferences; diff --git a/backend/migrations/000004_user_preferences.up.sql b/backend/migrations/000004_user_preferences.up.sql new file mode 100644 index 0000000..d62d6ab --- /dev/null +++ b/backend/migrations/000004_user_preferences.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/frontend/src/lib/actions/portal.ts b/frontend/src/lib/actions/portal.ts new file mode 100644 index 0000000..45755bd --- /dev/null +++ b/frontend/src/lib/actions/portal.ts @@ -0,0 +1,10 @@ +import type { Action } from 'svelte/action'; + +export const portal: Action = (node) => { + document.body.appendChild(node); + return { + destroy() { + node.remove(); + } + }; +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b0cc522..7d45bc8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,6 @@ import { goto } from '$app/navigation'; import { auth, type Tokens, type User } from './stores/auth.svelte'; +import type { Preferences } from './types'; export class ApiError extends Error { status: number; @@ -97,5 +98,10 @@ export const authApi = { }), logout: (refresh_token: string) => api('/auth/logout', { method: 'POST', body: { refresh_token } }), - me: () => api<{ user: User }>('/auth/me') + me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'), + savePreferences: (preferences: Preferences) => + api<{ preferences: Preferences }>('/auth/me/preferences', { + method: 'PATCH', + body: preferences + }) }; diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index b9d59e1..37ee6a9 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -5,9 +5,11 @@ import CoverPicker from './CoverPicker.svelte'; import Comments from './Comments.svelte'; import PanelSection from './PanelSection.svelte'; + import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte'; import { albums } from '$lib/stores/albums.svelte'; import { auth } from '$lib/stores/auth.svelte'; - import type { Album, Photo } from '$lib/types'; + import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif'; + import type { Album, Photo, Preferences } from '$lib/types'; const albumId = $derived($page.params.id ?? ''); const photoId = $derived($page.params.photoId ?? ''); @@ -28,203 +30,41 @@ const exif = $derived((photo?.exif ?? {}) as Record); - const EXIF_LABELS: Record = { - Make: 'Производитель', - Model: 'Модель', - LensMake: 'Производитель объектива', - LensModel: 'Объектив', - ExposureTime: 'Выдержка', - ShutterSpeedValue: 'Выдержка (APEX)', - FNumber: 'Диафрагма', - ApertureValue: 'Диафрагма (APEX)', - MaxApertureValue: 'Макс. диафрагма', - FocalLength: 'Фокусное расстояние', - FocalLengthIn35mmFilm: 'Фокусное (35 мм)', - ISOSpeedRatings: 'ISO', - PhotographicSensitivity: 'ISO', - ExposureProgram: 'Программа съёмки', - ExposureMode: 'Режим экспозиции', - ExposureBiasValue: 'Экспокоррекция', - BrightnessValue: 'Яркость (APEX)', - MeteringMode: 'Режим замера', - LightSource: 'Источник света', - Flash: 'Вспышка', - WhiteBalance: 'Баланс белого', - Orientation: 'Ориентация', - Software: 'ПО', - ColorSpace: 'Цветовое пространство', - SceneCaptureType: 'Тип сцены', - Contrast: 'Контраст', - Saturation: 'Насыщенность', - Sharpness: 'Резкость', - CustomRendered: 'Обработка', - GainControl: 'Усиление', - SensingMethod: 'Метод съёмки', - SubjectDistanceRange: 'Дистанция', - DigitalZoomRatio: 'Цифровой зум', - CompressedBitsPerPixel: 'Сжатие (бит/пиксель)', - ImageDescription: 'Описание' - }; - const EXIF_SKIP = new Set([ - 'GPSLatitude', - 'GPSLongitude', - 'GPSLatitudeRef', - 'GPSLongitudeRef', - 'GPSAltitude', - 'GPSAltitudeRef', - 'GPSVersionID', - 'GPSDateStamp', - 'GPSTimeStamp', - 'GPSProcessingMethod', - 'GPSAreaInformation', - 'GPSImgDirection', - 'GPSImgDirectionRef', - 'GPSDestBearing', - 'GPSDestBearingRef', - 'GPSSpeed', - 'GPSSpeedRef', - 'GPSTrack', - 'GPSTrackRef', - 'GPSMapDatum', - 'GPSDifferential', - 'DateTime', - 'DateTimeDigitized', - 'DateTimeOriginal', - 'SubSecTime', - 'SubSecTimeDigitized', - 'SubSecTimeOriginal', - 'ExifVersion', - 'FlashpixVersion', - 'InteroperabilityVersion', - 'InteroperabilityIndex', - 'ExifTag', - 'GPSTag', - 'InteroperabilityTag', - 'ExifIFDPointer', - 'GPSInfoIFDPointer', - 'ComponentsConfiguration', - 'MakerNote', - 'UserComment', - 'FileSource', - 'SceneType', - 'PhotometricInterpretation', - 'YCbCrPositioning', - 'ResolutionUnit', - 'BitsPerSample', - 'XResolution', - 'YResolution', - 'ImageWidth', - 'ImageLength', - 'PixelXDimension', - 'PixelYDimension' - ]); - - const EXIF_ENUMS: Record> = { - Orientation: { - 1: 'Горизонтальная', - 2: 'Зеркальная (гориз.)', - 3: 'Повёрнута на 180°', - 4: 'Зеркальная (верт.)', - 5: 'Зеркальная + 90° CW', - 6: 'Повёрнута на 90° CW', - 7: 'Зеркальная + 90° CCW', - 8: 'Повёрнута на 90° CCW' - }, - ExposureProgram: { - 0: 'Не задано', - 1: 'Ручная', - 2: 'Программная', - 3: 'Приоритет диафрагмы', - 4: 'Приоритет выдержки', - 5: 'Творческая', - 6: 'Спортивная', - 7: 'Портрет', - 8: 'Пейзаж' - }, - ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' }, - MeteringMode: { - 0: 'Неизвестно', - 1: 'Средневзвешенный', - 2: 'Центрально-взвешенный', - 3: 'Точечный', - 4: 'Многозонный', - 5: 'Матричный', - 6: 'Частичный', - 255: 'Другое' - }, - LightSource: { - 0: 'Неизвестно', - 1: 'Дневной свет', - 2: 'Люминесцентный', - 3: 'Лампа накаливания', - 4: 'Вспышка', - 9: 'Хорошая погода', - 10: 'Облачно', - 11: 'Тень' - }, - Flash: { - 0: 'Не сработала', - 1: 'Сработала', - 9: 'Сработала (принуд.)', - 16: 'Отключена', - 24: 'Не сработала (авто)', - 25: 'Сработала (авто)' - }, - WhiteBalance: { 0: 'Авто', 1: 'Ручной' }, - SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' }, - Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' }, - Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' }, - Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' }, - CustomRendered: { 0: 'Обычная', 1: 'Особая' }, - GainControl: { - 0: 'Нет', - 1: 'Малое усиление', - 2: 'Большое усиление', - 3: 'Низкое усиление', - 4: 'Высокое усиление' - }, - SensingMethod: { - 1: 'Не задано', - 2: 'Однокристальный', - 3: 'Двухчиповый', - 4: 'Трёхчиповый', - 5: 'Цветной последовательный', - 7: 'Трёхлинейный', - 8: 'Цветной последовательный' - }, - SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' }, - ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' } - }; - - function fmtExif(key: string, value: unknown): string { - if (value == null) return ''; - const n = Number(value); - if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) { - return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`; - } - if ( - (key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') && - !Number.isNaN(n) - ) { - return `f/${n}`; - } - if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) { - return `${n} мм`; - } - if (key === 'ExposureBiasValue' && !Number.isNaN(n)) { - return `${n > 0 ? '+' : ''}${n} EV`; - } - const enums = EXIF_ENUMS[key]; - if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n]; - return String(value); - } - - const exifRows = $derived( - Object.entries(exif) - .filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '') - .map(([k, v]) => ({ key: k, label: EXIF_LABELS[k] ?? k, value: fmtExif(k, v) })) - .sort((a, b) => a.label.localeCompare(b.label, 'ru')) + 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); + + 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; + } + } $effect(() => { const id = albumId; @@ -247,6 +87,7 @@ const id = photoId; editingName = false; nameError = ''; + showAll = false; if (!id) { photo = null; return; @@ -362,6 +203,20 @@
+ {#snippet actions()} + + {/snippet}
Автор{photo.uploader.name}
Файл{formatSize(photo.size)}
@@ -370,12 +225,23 @@ Разрешение {photo.width}×{photo.height}
- {#each exifRows as row (row.key)} + {#each showAll ? allExifRows : visibleExifRows as row (row.key)}
{row.label} {row.value}
{/each} + {#if hiddenCount > 0 && !showAll} + + {/if} {#if photo.taken_at}
Снято @@ -399,6 +265,19 @@
+ + {#if settingsOpen} + savePreferences({})} + onClose={() => (settingsOpen = false)} + /> + {/if} {/if} {:else if album}
diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte index 862640f..5903918 100644 --- a/frontend/src/lib/components/ConfirmDialog.svelte +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -1,5 +1,6 @@
- +
+ + {#if actions} +
{@render actions()}
+ {/if} +
{#if isOpen}
{@render children()}
{/if} diff --git a/frontend/src/lib/components/PhotoInfoSettingsDialog.svelte b/frontend/src/lib/components/PhotoInfoSettingsDialog.svelte new file mode 100644 index 0000000..6d921bc --- /dev/null +++ b/frontend/src/lib/components/PhotoInfoSettingsDialog.svelte @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/lib/exif.ts b/frontend/src/lib/exif.ts new file mode 100644 index 0000000..27c66db --- /dev/null +++ b/frontend/src/lib/exif.ts @@ -0,0 +1,224 @@ +export const EXIF_LABELS: Record = { + Make: 'Производитель', + Model: 'Модель', + LensMake: 'Производитель объектива', + LensModel: 'Объектив', + ExposureTime: 'Выдержка', + ShutterSpeedValue: 'Выдержка (APEX)', + FNumber: 'Диафрагма', + ApertureValue: 'Диафрагма (APEX)', + MaxApertureValue: 'Макс. диафрагма', + FocalLength: 'Фокусное расстояние', + FocalLengthIn35mmFilm: 'Фокусное (35 мм)', + ISOSpeedRatings: 'ISO', + PhotographicSensitivity: 'ISO', + ExposureProgram: 'Программа съёмки', + ExposureMode: 'Режим экспозиции', + ExposureBiasValue: 'Экспокоррекция', + BrightnessValue: 'Яркость (APEX)', + MeteringMode: 'Режим замера', + LightSource: 'Источник света', + Flash: 'Вспышка', + WhiteBalance: 'Баланс белого', + Orientation: 'Ориентация', + Software: 'ПО', + ColorSpace: 'Цветовое пространство', + SceneCaptureType: 'Тип сцены', + Contrast: 'Контраст', + Saturation: 'Насыщенность', + Sharpness: 'Резкость', + CustomRendered: 'Обработка', + GainControl: 'Усиление', + SensingMethod: 'Метод съёмки', + SubjectDistanceRange: 'Дистанция', + DigitalZoomRatio: 'Цифровой зум', + CompressedBitsPerPixel: 'Сжатие (бит/пиксель)', + ImageDescription: 'Описание' +}; + +export const EXIF_SKIP = new Set([ + 'GPSLatitude', + 'GPSLongitude', + 'GPSLatitudeRef', + 'GPSLongitudeRef', + 'GPSAltitude', + 'GPSAltitudeRef', + 'GPSVersionID', + 'GPSDateStamp', + 'GPSTimeStamp', + 'GPSProcessingMethod', + 'GPSAreaInformation', + 'GPSImgDirection', + 'GPSImgDirectionRef', + 'GPSDestBearing', + 'GPSDestBearingRef', + 'GPSSpeed', + 'GPSSpeedRef', + 'GPSTrack', + 'GPSTrackRef', + 'GPSMapDatum', + 'GPSDifferential', + 'DateTime', + 'DateTimeDigitized', + 'DateTimeOriginal', + 'SubSecTime', + 'SubSecTimeDigitized', + 'SubSecTimeOriginal', + 'ExifVersion', + 'FlashpixVersion', + 'InteroperabilityVersion', + 'InteroperabilityIndex', + 'ExifTag', + 'GPSTag', + 'InteroperabilityTag', + 'ExifIFDPointer', + 'GPSInfoIFDPointer', + 'ComponentsConfiguration', + 'MakerNote', + 'UserComment', + 'FileSource', + 'SceneType', + 'PhotometricInterpretation', + 'YCbCrPositioning', + 'ResolutionUnit', + 'BitsPerSample', + 'XResolution', + 'YResolution', + 'ImageWidth', + 'ImageLength', + 'PixelXDimension', + 'PixelYDimension' +]); + +export const EXIF_ENUMS: Record> = { + Orientation: { + 1: 'Горизонтальная', + 2: 'Зеркальная (гориз.)', + 3: 'Повёрнута на 180°', + 4: 'Зеркальная (верт.)', + 5: 'Зеркальная + 90° CW', + 6: 'Повёрнута на 90° CW', + 7: 'Зеркальная + 90° CCW', + 8: 'Повёрнута на 90° CCW' + }, + ExposureProgram: { + 0: 'Не задано', + 1: 'Ручная', + 2: 'Программная', + 3: 'Приоритет диафрагмы', + 4: 'Приоритет выдержки', + 5: 'Творческая', + 6: 'Спортивная', + 7: 'Портрет', + 8: 'Пейзаж' + }, + ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' }, + MeteringMode: { + 0: 'Неизвестно', + 1: 'Средневзвешенный', + 2: 'Центрально-взвешенный', + 3: 'Точечный', + 4: 'Многозонный', + 5: 'Матричный', + 6: 'Частичный', + 255: 'Другое' + }, + LightSource: { + 0: 'Неизвестно', + 1: 'Дневной свет', + 2: 'Люминесцентный', + 3: 'Лампа накаливания', + 4: 'Вспышка', + 9: 'Хорошая погода', + 10: 'Облачно', + 11: 'Тень' + }, + Flash: { + 0: 'Не сработала', + 1: 'Сработала', + 9: 'Сработала (принуд.)', + 16: 'Отключена', + 24: 'Не сработала (авто)', + 25: 'Сработала (авто)' + }, + WhiteBalance: { 0: 'Авто', 1: 'Ручной' }, + SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' }, + Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' }, + Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' }, + Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' }, + CustomRendered: { 0: 'Обычная', 1: 'Особая' }, + GainControl: { + 0: 'Нет', + 1: 'Малое усиление', + 2: 'Большое усиление', + 3: 'Низкое усиление', + 4: 'Высокое усиление' + }, + SensingMethod: { + 1: 'Не задано', + 2: 'Однокристальный', + 3: 'Двухчиповый', + 4: 'Трёхчиповый', + 5: 'Цветной последовательный', + 7: 'Трёхлинейный', + 8: 'Цветной последовательный' + }, + SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' }, + ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' } +}; + +export function formatExifValue(key: string, value: unknown): string { + if (value == null) return ''; + const n = Number(value); + if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) { + return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`; + } + if ( + (key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') && + !Number.isNaN(n) + ) { + return `f/${n}`; + } + if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) { + return `${n} мм`; + } + if (key === 'ExposureBiasValue' && !Number.isNaN(n)) { + return `${n > 0 ? '+' : ''}${n} EV`; + } + const enums = EXIF_ENUMS[key]; + if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n]; + return String(value); +} + +export interface ExifRow { + key: string; + label: string; + value: string; +} + +export const EXIF_BASE: string[] = [ + 'Make', + 'Model', + 'LensModel', + 'ExposureTime', + 'FNumber', + 'ISOSpeedRatings', + 'FocalLength', + 'FocalLengthIn35mmFilm' +]; + +export function buildExifRows( + exif: Record, + labels: Record = {} +): ExifRow[] { + return Object.entries(exif) + .filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '') + .map(([k, v]) => ({ + key: k, + label: labels[k] ?? EXIF_LABELS[k] ?? k, + value: formatExifValue(k, v) + })) + .sort((a, b) => a.label.localeCompare(b.label, 'ru')); +} + +export const EXIF_CATALOG: string[] = Object.keys(EXIF_LABELS); diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 609a58d..2319b24 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -46,7 +46,9 @@ export const icons = { wallpaper: { path: '' }, fullscreen: { path: '' }, fullscreen_exit: { path: '' }, - contrast: { path: '' } + contrast: { path: '' }, + check_all: { path: '' }, + uncheck_all: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index 4e9cce8..8c7c26c 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -1,8 +1,11 @@ +import type { Preferences } from '$lib/types'; + export interface User { id: string; email: string; name: string; avatar_url?: string; + preferences?: Preferences; } export interface Tokens { @@ -56,6 +59,18 @@ class AuthStore { } } + get preferences(): Preferences { + return this.user?.preferences ?? {}; + } + + setPreferences(preferences: Preferences) { + if (!this.user) return; + this.user = { ...this.user, preferences }; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(USER_KEY, JSON.stringify(this.user)); + } + } + clear() { this.access = null; this.refresh = null; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3482dc4..7b1b5ab 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,8 +1,18 @@ +export interface PhotoInfoPreferences { + visible?: string[]; + labels?: Record; +} + +export interface Preferences { + photo_info?: PhotoInfoPreferences; +} + export interface User { id: string; email?: string; name: string; avatar_url?: string; + preferences?: Preferences; } export interface Album { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1deca08..1cef464 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -15,7 +15,7 @@ if (auth.isAuthenticated && !auth.user) { authApi .me() - .then((data) => auth.setUser(data.user)) + .then((data) => auth.setUser({ ...data.user, preferences: data.preferences ?? {} })) .catch(() => {}); } }); diff --git a/openspec/changes/lightroom-workspace-ui/design.md b/openspec/changes/lightroom-workspace-ui/design.md index cbb931e..1a05bab 100644 --- a/openspec/changes/lightroom-workspace-ui/design.md +++ b/openspec/changes/lightroom-workspace-ui/design.md @@ -132,20 +132,23 @@ filmstrip, панель метаданных/комментариев, тулб панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома обновляет запись в списке. -### 17. Настройки информации: оверрайды в профиле пользователя -Настройки раздела «Информация» хранятся в профиле как оверрайды, а не как полный список: +### 17. Настройки информации: явный список видимых в профиле пользователя +Настройки раздела «Информация» хранятся в профиле как явный список видимых атрибутов: `users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида -`{ "hidden": ["Contrast", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие ключа -означает, что атрибут видим и подпись дефолтная, поэтому новые и незнакомые теги EXIF не -«теряются» и не требуют миграций данных. `GET /auth/me` отдаёт `preferences` вместе с -пользователем; сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной -валидацией: строки и массивы строк, ограничение длины подписи/ключа и числа скрытых ключей). -Настройки не входят в `PublicUser` (приватны). UI: `PanelSection` получает слот `actions` под -шестерёнку; модалка `PhotoInfoSettingsDialog` строится из объединения известного каталога -подписей и ключей текущего фото; список в панели фильтруется по оверрайдам, а скрытые -атрибуты сворачиваются под кнопку «…» (раскрытие — временное, для текущего фото). -Альтернативы: полный упорядоченный список в профиле (хрупок к новым тегам, нужны миграции) и -хранение на клиенте (не переносится между устройствами) — отклонены. +`{ "visible": ["Make", "Model", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие +секции `photo_info` означает базовый набор по умолчанию (камера, объектив, выдержка, +диафрагма, ISO, фокусное расстояние) — не «все атрибуты». Список `visible` хранится явно, +поэтому различимо состояние «ничего не выбрано»; новые и незнакомые теги не «теряются» (их +можно включить в диалоге). `GET /auth/me` отдаёт `preferences` вместе с пользователем; +сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной валидацией: +строки и массивы строк, ограничение длины подписи/ключа и числа записей). Пустой объект +(`{}`) удаляет настройки, возвращая базовый набор. Настройки не входят в `PublicUser` +(приватны). UI: `PanelSection` получает слот `actions` под шестерёнку; модалка +`PhotoInfoSettingsDialog` строится из объединения известного каталога подписей и ключей +текущего фото и даёт действия «отметить все»/«снять все» и «Сбросить»; список в панели +фильтруется по сохранённым видимым атрибутам, а остальные сворачиваются под кнопку «…» +(раскрытие — временное, для текущего фото). Альтернатива с оверрайдами `hidden` (пусто = все +видимы) отклонена: не даёт базовый набор по умолчанию. ### 18. Метаданные фото: устойчивый парсер и хранение в jsonb EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`) diff --git a/openspec/changes/lightroom-workspace-ui/proposal.md b/openspec/changes/lightroom-workspace-ui/proposal.md index 6244448..2d84718 100644 --- a/openspec/changes/lightroom-workspace-ui/proposal.md +++ b/openspec/changes/lightroom-workspace-ui/proposal.md @@ -42,9 +42,11 @@ - **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием. - **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую - модалку со списком EXIF-атрибутов: переключатели видимости и пользовательские подписи. - Скрытые атрибуты сворачиваются под элементом «…» (клик раскрывает все). Настройки хранятся - в профиле пользователя (server-side) и применяются ко всем фото. + модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять + все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…» + (клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются + ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет + настройки из профиля. - **Счётчики.** В ответе альбома — число фото и участников. - **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт, `neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая → 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 2b5841f..97f9c06 100644 --- a/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md +++ b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md @@ -219,21 +219,27 @@ SHALL отображаться вне каркаса, но в общей тем ### Requirement: Настройки списка атрибутов информации Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим -модальное окно, в котором пользователь SHALL выбирать отображаемые EXIF-атрибуты и задавать -их подписи. Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле -пользователя. При отсутствии настроек SHALL отображаться все доступные атрибуты. +модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые +атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и +«снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле +пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов +(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние). #### Scenario: Открытие настроек - **WHEN** пользователь нажимает иконку настроек в разделе «Информация» -- **THEN** открывается модальное окно со списком EXIF-атрибутов: переключатели видимости и поля подписей +- **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все» + +#### Scenario: Значения по умолчанию +- **WHEN** у пользователя нет сохранённых настроек +- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…» #### Scenario: Сохранение настроек -- **WHEN** пользователь скрывает атрибут, задаёт подпись и сохраняет +- **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет - **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле #### Scenario: Сброс настроек -- **WHEN** пользователь сбрасывает настройки к значениям по умолчанию -- **THEN** показываются все атрибуты с исходными подписями +- **WHEN** пользователь сбрасывает настройки +- **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями ### Requirement: Свёртка скрытых атрибутов Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент diff --git a/openspec/changes/lightroom-workspace-ui/tasks.md b/openspec/changes/lightroom-workspace-ui/tasks.md index 21da5ff..21a4e87 100644 --- a/openspec/changes/lightroom-workspace-ui/tasks.md +++ b/openspec/changes/lightroom-workspace-ui/tasks.md @@ -59,10 +59,11 @@ ## 9. Настройки информации пользователя -- [ ] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает. -- [ ] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется. -- [ ] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы. -- [ ] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`. -- [ ] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле. -- [ ] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы. -- [ ] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`. +- [x] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает. +- [x] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется. +- [x] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы. +- [x] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`. +- [x] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле. +- [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы. +- [x] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`. +- [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12). From 642e3bcf8add7b8b70cf909b51932421996fe599 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 21:26:46 +0300 Subject: [PATCH 14/18] fix(frontend): immediate viewer chrome and correct f fullscreen - remove all hide timers in Viewer; top/bottom bars and arrows now show/hide immediately - arrows reveal only within the viewer bounds (no longer stay visible when the pointer is over the left/right panels) - f (and the viewer button) toggle the viewer's own fullscreen via nested fullscreen, so f works inside app-level fullscreen without turning it off --- frontend/src/lib/components/Viewer.svelte | 84 ++--------------------- 1 file changed, 7 insertions(+), 77 deletions(-) diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 2bef15f..d5dac1a 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -37,12 +37,7 @@ let showBottom = $state(false); let showLeft = $state(false); let showRight = $state(false); - const EDGE = 8; - let hideTopTimer: ReturnType | undefined; - let hideBottomTimer: ReturnType | undefined; - let hideLeftTimer: ReturnType | undefined; - let hideRightTimer: ReturnType | undefined; let dragging = false; let startX = 0; @@ -99,13 +94,7 @@ resetBars(); }; document.addEventListener('fullscreenchange', onFs); - return () => { - document.removeEventListener('fullscreenchange', onFs); - clearTimeout(hideTopTimer); - clearTimeout(hideBottomTimer); - clearTimeout(hideLeftTimer); - clearTimeout(hideRightTimer); - }; + return () => document.removeEventListener('fullscreenchange', onFs); }); const topClass = $derived( @@ -132,14 +121,6 @@ ); function resetBars() { - clearTimeout(hideTopTimer); - clearTimeout(hideBottomTimer); - clearTimeout(hideLeftTimer); - clearTimeout(hideRightTimer); - hideTopTimer = undefined; - hideBottomTimer = undefined; - hideLeftTimer = undefined; - hideRightTimer = undefined; showTop = false; showBottom = false; showLeft = false; @@ -152,70 +133,19 @@ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; } - function scheduleTop(shouldShow: boolean) { - if (shouldShow) { - clearTimeout(hideTopTimer); - hideTopTimer = undefined; - showTop = true; - } else if (showTop && !hideTopTimer) { - hideTopTimer = setTimeout(() => { - showTop = false; - hideTopTimer = undefined; - }, 1000); - } - } - - function scheduleBottom(shouldShow: boolean) { - if (shouldShow) { - clearTimeout(hideBottomTimer); - hideBottomTimer = undefined; - showBottom = true; - } else if (showBottom && !hideBottomTimer) { - hideBottomTimer = setTimeout(() => { - showBottom = false; - hideBottomTimer = undefined; - }, 1000); - } - } - - function scheduleLeft(shouldShow: boolean) { - if (shouldShow) { - clearTimeout(hideLeftTimer); - hideLeftTimer = undefined; - showLeft = true; - } else if (showLeft && !hideLeftTimer) { - hideLeftTimer = setTimeout(() => { - showLeft = false; - hideLeftTimer = undefined; - }, 300); - } - } - - function scheduleRight(shouldShow: boolean) { - if (shouldShow) { - clearTimeout(hideRightTimer); - hideRightTimer = undefined; - showRight = true; - } else if (showRight && !hideRightTimer) { - hideRightTimer = setTimeout(() => { - showRight = false; - hideRightTimer = undefined; - }, 300); - } - } - function onWinMouseMove(e: MouseEvent) { const r = viewerEl?.getBoundingClientRect(); if (r) { const zoneX = r.width / 6; - scheduleLeft(!zoomed && e.clientX <= r.left + zoneX); - scheduleRight(!zoomed && e.clientX >= r.right - zoneX); + const insideY = e.clientY >= r.top && e.clientY <= r.bottom; + showLeft = !zoomed && insideY && e.clientX >= r.left && e.clientX <= r.left + zoneX; + showRight = !zoomed && insideY && e.clientX <= r.right && e.clientX >= r.right - zoneX; } if (!isFullscreen) return; const nearTop = e.clientY <= EDGE; const nearBottom = e.clientY >= window.innerHeight - EDGE; - scheduleTop(nearTop || inRect(topEl, e.clientX, e.clientY)); - scheduleBottom(nearBottom || inRect(bottomEl, e.clientX, e.clientY)); + showTop = nearTop || inRect(topEl, e.clientX, e.clientY); + showBottom = nearBottom || inRect(bottomEl, e.clientX, e.clientY); } function baseWidth(): number { @@ -313,7 +243,7 @@ async function toggleViewerFullscreen() { if (!viewerEl) return; try { - if (document.fullscreenElement) await document.exitFullscreen(); + if (document.fullscreenElement === viewerEl) await document.exitFullscreen(); else await viewerEl.requestFullscreen(); } catch { /* недоступно */ From 78996bc053e6294972f0633bc3243fa0fed5421a Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 21:33:59 +0300 Subject: [PATCH 15/18] feat(frontend): select photo in album grid and show info in panel - single click selects a photo (ring + aria-pressed) and shows its info in the right panel without navigating; double click opens the viewer - shared 'selection' store; panel uses the route photo in viewer mode and the selected photo in album mode; selection resets on album change - openspec: sync lightroom-workspace-ui (grid-selection requirement, viewer chrome wording without timers, nested f fullscreen, tasks 9.9/9.10) --- frontend/src/lib/components/AlbumPanel.svelte | 60 ++++++++++++------- frontend/src/lib/components/PhotoGrid.svelte | 13 +++- frontend/src/lib/stores/selection.svelte.ts | 18 ++++++ .../src/routes/(app)/albums/[id]/+page.svelte | 7 +++ .../changes/lightroom-workspace-ui/design.md | 20 +++++-- .../lightroom-workspace-ui/proposal.md | 1 + .../specs/ui-workspace/spec.md | 24 +++++++- .../changes/lightroom-workspace-ui/tasks.md | 4 +- 8 files changed, 114 insertions(+), 33 deletions(-) create mode 100644 frontend/src/lib/stores/selection.svelte.ts diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index 37ee6a9..6a705f5 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -8,6 +8,7 @@ import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte'; import { albums } from '$lib/stores/albums.svelte'; import { auth } from '$lib/stores/auth.svelte'; + import { selection } from '$lib/stores/selection.svelte'; import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif'; import type { Album, Photo, Preferences } from '$lib/types'; @@ -25,10 +26,15 @@ let savingName = $state(false); let nameError = $state(''); - const canEdit = $derived(role === 'owner' || role === 'admin'); - const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id)); + const selectedPhoto = $derived(selection.albumId === albumId ? selection.photo : null); + const panelPhoto = $derived(photoId ? photo : selectedPhoto); - const exif = $derived((photo?.exif ?? {}) as Record); + const canEdit = $derived(role === 'owner' || role === 'admin'); + const canRename = $derived( + !!panelPhoto && (canEdit || panelPhoto.uploader_id === auth.user?.id) + ); + + const exif = $derived((panelPhoto?.exif ?? {}) as Record); const prefs = $derived(auth.preferences); const labelOverrides = $derived(prefs.photo_info?.labels ?? {}); @@ -97,24 +103,33 @@ .catch((e) => (error = (e as Error).message)); }); + $effect(() => { + // сброс временного состояния панели при выборе другого фото в сетке + selectedPhoto?.id; + editingName = false; + nameError = ''; + showAll = false; + }); + function startEdit() { - if (!photo) return; - nameDraft = photo.original_filename; + if (!panelPhoto) return; + nameDraft = panelPhoto.original_filename; nameError = ''; editingName = true; } async function saveName(ev: Event) { ev.preventDefault(); - if (!photo || !nameDraft.trim()) return; + if (!panelPhoto || !nameDraft.trim()) return; savingName = true; nameError = ''; try { - const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, { + const data = await api<{ photo: Photo }>(`/photos/${panelPhoto.id}`, { method: 'PATCH', body: { original_filename: nameDraft.trim() } }); photo = data.photo; + if (selectedPhoto?.id === data.photo.id) selection.select(albumId, data.photo); editingName = false; } catch (e) { nameError = (e as Error).message; @@ -154,8 +169,8 @@ const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs'; -{#if photoId} - {#if photo} +{#if panelPhoto} + {@const p = panelPhoto}
{#if editingName} @@ -195,10 +210,10 @@ title="Переименовать" onclick={startEdit} > - {photo.original_filename} + {p.original_filename} {:else} -

{photo.original_filename}

+

{p.original_filename}

{/if}
@@ -218,12 +233,12 @@ {/snippet}
-
Автор{photo.uploader.name}
-
Файл{formatSize(photo.size)}
-
Тип{photo.mime_type}
+
Автор{p.uploader.name}
+
Файл{formatSize(p.size)}
+
Тип{p.mime_type}
Разрешение - {photo.width}×{photo.height} + {p.width}×{p.height}
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
@@ -242,27 +257,27 @@ … {/if} - {#if photo.taken_at} + {#if p.taken_at}
Снято - {new Date(photo.taken_at).toLocaleString()} + {new Date(p.taken_at).toLocaleString()}
{/if} - {#if photo.latitude != null && photo.longitude != null} + {#if p.latitude != null && p.longitude != null}
Координаты - {photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)} + {p.latitude.toFixed(5)}, {p.longitude.toFixed(5)}
{/if}
Загружено - {new Date(photo.created_at).toLocaleString()} + {new Date(p.created_at).toLocaleString()}
- +
@@ -278,8 +293,7 @@ onClose={() => (settingsOpen = false)} /> {/if} - {/if} -{:else if album} +{:else if album && !photoId}
diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte index bc5bacc..a80dc38 100644 --- a/frontend/src/lib/components/PhotoGrid.svelte +++ b/frontend/src/lib/components/PhotoGrid.svelte @@ -5,10 +5,12 @@ interface Props { photos: Photo[]; + selectedId?: string | null; + onSelect?: (index: number) => void; onOpen?: (index: number) => void; } - let { photos, onOpen }: Props = $props(); + let { photos, selectedId = null, onSelect, onOpen }: Props = $props(); {#if photos.length === 0} @@ -22,8 +24,13 @@
  • + + + + +
  • +{/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}