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"`.