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)
This commit is contained in:
@@ -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) {
|
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) {
|
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) {
|
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 {
|
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 {
|
if err := s.store.Update(ctx, a); err != nil {
|
||||||
return nil, err
|
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)
|
s.hub.Publish(albumID, "album.updated", a)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||||
var a models.Album
|
var a models.Album
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
|
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||||
FROM albums WHERE id = $1`, id).
|
COALESCE(p.thumb_key, ''),
|
||||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
|
(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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
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) {
|
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
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
|
FROM albums a
|
||||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
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
|
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||||
ORDER BY a.created_at DESC`, userID)
|
ORDER BY a.created_at DESC`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +79,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
|||||||
out := []models.Album{}
|
out := []models.Album{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.Album
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type Album struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) {
|
|||||||
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
|
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.store.SetCover(ctx, albumID, photoID)
|
s.store.SetCoverIfEmpty(ctx, albumID, photoID)
|
||||||
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
|
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
|
||||||
if err := s.attachURLs(ctx, p); err == nil {
|
if err := s.attachURLs(ctx, p); err == nil {
|
||||||
s.hub.Publish(albumID, "photo.created", p)
|
s.hub.Publish(albumID, "photo.created", p)
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
||||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
|
_, _ = s.pool.Exec(ctx,
|
||||||
|
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
||||||
|
albumID, photoID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"shared-album/internal/auth"
|
"shared-album/internal/auth"
|
||||||
"shared-album/internal/members"
|
"shared-album/internal/members"
|
||||||
"shared-album/internal/models"
|
"shared-album/internal/models"
|
||||||
|
"shared-album/internal/photos"
|
||||||
"shared-album/internal/sharelinks"
|
"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) {
|
func TestMemberRepository(t *testing.T) {
|
||||||
pool := testPool(t)
|
pool := testPool(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk;
|
||||||
@@ -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;
|
||||||
+11
-5
@@ -15,22 +15,26 @@
|
|||||||
--primary-fg: 0 0% 100%;
|
--primary-fg: 0 0% 100%;
|
||||||
--danger: 0 84% 60%;
|
--danger: 0 84% 60%;
|
||||||
--success: 142 71% 45%;
|
--success: 142 71% 45%;
|
||||||
|
--chrome: 0 0% 98%;
|
||||||
|
--canvas: 0 0% 100%;
|
||||||
--shadow: 0 0% 0% / 0.08;
|
--shadow: 0 0% 0% / 0.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Тёмная */
|
/* Тёмная (графит Lightroom) */
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--bg: 0 0% 7%;
|
--bg: 0 0% 9%;
|
||||||
--bg-elevated: 0 0% 11%;
|
--bg-elevated: 0 0% 15%;
|
||||||
--bg-muted: 0 0% 16%;
|
--bg-muted: 0 0% 18%;
|
||||||
--border: 0 0% 22%;
|
--border: 0 0% 22%;
|
||||||
--text: 0 0% 96%;
|
--text: 0 0% 92%;
|
||||||
--text-muted: 0 0% 65%;
|
--text-muted: 0 0% 65%;
|
||||||
--text-subtle: 0 0% 45%;
|
--text-subtle: 0 0% 45%;
|
||||||
--primary: 217 91% 60%;
|
--primary: 217 91% 60%;
|
||||||
--primary-fg: 0 0% 100%;
|
--primary-fg: 0 0% 100%;
|
||||||
--danger: 0 84% 65%;
|
--danger: 0 84% 65%;
|
||||||
--success: 142 65% 50%;
|
--success: 142 65% 50%;
|
||||||
|
--chrome: 0 0% 13%;
|
||||||
|
--canvas: 0 0% 9%;
|
||||||
--shadow: 0 0% 0% / 0.4;
|
--shadow: 0 0% 0% / 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +51,8 @@
|
|||||||
--primary-fg: 0 0% 100%;
|
--primary-fg: 0 0% 100%;
|
||||||
--danger: 0 72% 55%;
|
--danger: 0 72% 55%;
|
||||||
--success: 142 55% 42%;
|
--success: 142 55% 42%;
|
||||||
|
--chrome: 40 22% 92%;
|
||||||
|
--canvas: 40 20% 96%;
|
||||||
--shadow: 30 20% 20% / 0.12;
|
--shadow: 30 20% 20% / 0.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru" data-theme="light">
|
<html lang="ru" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var stored = localStorage.getItem('theme') || 'system';
|
var stored = localStorage.getItem('theme') || 'dark';
|
||||||
var resolved = stored;
|
var resolved = stored;
|
||||||
if (stored === 'system') {
|
if (stored === 'system') {
|
||||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import CoverPicker from './CoverPicker.svelte';
|
||||||
|
import Comments from './Comments.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
const photoId = $derived($page.params.photoId ?? '');
|
||||||
|
|
||||||
|
let album = $state<Album | null>(null);
|
||||||
|
let role = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let picking = $state(false);
|
||||||
|
|
||||||
|
let photo = $state<Photo | null>(null);
|
||||||
|
|
||||||
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
error = '';
|
||||||
|
if (!id || photoId) {
|
||||||
|
if (!id) {
|
||||||
|
album = null;
|
||||||
|
role = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
album = data.album;
|
||||||
|
role = data.role;
|
||||||
|
albums.upsert(data.album);
|
||||||
|
})
|
||||||
|
.catch((e) => (error = (e as Error).message));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = photoId;
|
||||||
|
if (!id) {
|
||||||
|
photo = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
|
.then((data) => (photo = data.photo))
|
||||||
|
.catch((e) => (error = (e as Error).message));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resetCover() {
|
||||||
|
if (!album) return;
|
||||||
|
try {
|
||||||
|
album = await albums.setCover(album.id, null);
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLink =
|
||||||
|
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
|
||||||
|
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if photoId}
|
||||||
|
{#if photo}
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<h2 class="text-text truncate text-sm font-semibold" title={photo.original_filename}>
|
||||||
|
{photo.original_filename}
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
|
||||||
|
{#if photo.taken_at}
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>Снято</span>
|
||||||
|
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>Размер</span>
|
||||||
|
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-text-muted mb-1 block text-xs font-semibold tracking-wide uppercase">
|
||||||
|
Комментарии
|
||||||
|
</span>
|
||||||
|
<Comments photoId={photo.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if album}
|
||||||
|
<div class="space-y-5 p-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-text-muted mb-2 block text-xs font-semibold tracking-wide uppercase">
|
||||||
|
Обложка
|
||||||
|
</span>
|
||||||
|
<div class="bg-bg-muted border-border aspect-video overflow-hidden rounded-lg border">
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<span class="flex h-full w-full items-center justify-center">
|
||||||
|
<Icon name="image" class="size-10 text-text-subtle" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="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"
|
||||||
|
onclick={() => (picking = true)}
|
||||||
|
>
|
||||||
|
<Icon name="wallpaper" class="size-4" />
|
||||||
|
Выбрать обложку
|
||||||
|
</button>
|
||||||
|
{#if album.cover_photo_id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center rounded-lg px-3 py-1.5 text-sm"
|
||||||
|
onclick={resetCover}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-text text-lg font-semibold">{album.title}</h2>
|
||||||
|
{#if album.description}<p class="text-text-muted text-sm">{album.description}</p>{/if}
|
||||||
|
<p class="text-text-subtle mt-1 text-xs">
|
||||||
|
{album.photos_count ?? 0} фото · {album.members_count ?? 0} участников
|
||||||
|
</p>
|
||||||
|
{#if error}<p class="text-danger mt-2 text-sm">{error}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="space-y-0.5" aria-label="Навигация по альбому">
|
||||||
|
<a class={navLink} href="/albums/{album.id}/members">
|
||||||
|
<Icon name="group" class="size-4" />
|
||||||
|
Участники
|
||||||
|
</a>
|
||||||
|
{#if canEdit}
|
||||||
|
<a class={navLink} href="/albums/{album.id}/settings">
|
||||||
|
<Icon name="settings" class="size-4" />
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a class={navLink} href="/albums/{album.id}/settings#share">
|
||||||
|
<Icon name="add_link" class="size-4" />
|
||||||
|
Ссылки для просмотра
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if picking}
|
||||||
|
<CoverPicker
|
||||||
|
albumId={album.id}
|
||||||
|
onClose={() => (picking = false)}
|
||||||
|
onSelected={(updated) => (album = updated)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import CreateAlbumDialog from './CreateAlbumDialog.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
const activeId = $derived($page.params.id ?? '');
|
||||||
|
let creating = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span class="text-text-muted text-xs font-semibold tracking-wide uppercase">Альбомы</span>
|
||||||
|
{#if auth.isAuthenticated}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||||
|
aria-label="Новый альбом"
|
||||||
|
title="Новый альбом"
|
||||||
|
onclick={() => (creating = true)}
|
||||||
|
>
|
||||||
|
<Icon name="add" class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !auth.isAuthenticated}
|
||||||
|
<p class="text-text-subtle p-3 text-sm">Войдите, чтобы видеть альбомы.</p>
|
||||||
|
{:else if albums.loading && albums.list.length === 0}
|
||||||
|
<p class="text-text-subtle p-3 text-sm">Загрузка…</p>
|
||||||
|
{:else if albums.list.length === 0}
|
||||||
|
<p class="text-text-subtle p-3 text-sm">Альбомов пока нет.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||||
|
{#each albums.list as album (album.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/albums/{album.id}"
|
||||||
|
class="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors {album.id ===
|
||||||
|
activeId
|
||||||
|
? 'bg-bg-muted text-text'
|
||||||
|
: 'text-text-muted hover:bg-bg-muted hover:text-text'}"
|
||||||
|
aria-current={album.id === activeId ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||||
|
>
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img
|
||||||
|
src={album.cover_thumb_url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Icon name="image" class="size-4 text-text-subtle" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{album.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<CreateAlbumDialog
|
||||||
|
onClose={() => (creating = false)}
|
||||||
|
onCreated={(album) => goto(`/albums/${album.id}`)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { authApi } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import ThemeSwitcher from './ThemeSwitcher.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
rail?: Snippet;
|
||||||
|
panel?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, rail, panel }: Props = $props();
|
||||||
|
|
||||||
|
let railOpen = $state(false);
|
||||||
|
let panelOpen = $state(false);
|
||||||
|
|
||||||
|
const RAIL = { min: 176, max: 480, key: 'rail-width', def: 240 };
|
||||||
|
const PANEL = { min: 220, max: 560, key: 'panel-width', def: 288 };
|
||||||
|
|
||||||
|
let railWidth = $state(RAIL.def);
|
||||||
|
let panelWidth = $state(PANEL.def);
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
function startResize(side: 'left' | 'right', ev: PointerEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const cfg = side === 'left' ? RAIL : PANEL;
|
||||||
|
const startX = ev.clientX;
|
||||||
|
const startW = side === 'left' ? railWidth : panelWidth;
|
||||||
|
const prevCursor = document.body.style.cursor;
|
||||||
|
const prevSelect = document.body.style.userSelect;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
const move = (e: PointerEvent) => {
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
const raw = side === 'left' ? startW + dx : startW - dx;
|
||||||
|
const v = Math.min(cfg.max, Math.max(cfg.min, raw));
|
||||||
|
if (side === 'left') railWidth = v;
|
||||||
|
else panelWidth = v;
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
document.body.style.cursor = prevCursor;
|
||||||
|
document.body.style.userSelect = prevSelect;
|
||||||
|
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudge(side: 'left' | 'right', ev: KeyboardEvent) {
|
||||||
|
const cfg = side === 'left' ? RAIL : PANEL;
|
||||||
|
let delta = 0;
|
||||||
|
if (ev.key === 'ArrowLeft') delta = side === 'left' ? -16 : 16;
|
||||||
|
else if (ev.key === 'ArrowRight') delta = side === 'left' ? 16 : -16;
|
||||||
|
else return;
|
||||||
|
ev.preventDefault();
|
||||||
|
if (side === 'left') railWidth = Math.min(cfg.max, Math.max(cfg.min, railWidth + delta));
|
||||||
|
else panelWidth = Math.min(cfg.max, Math.max(cfg.min, panelWidth + delta));
|
||||||
|
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle =
|
||||||
|
'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none';
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
if (auth.refresh) {
|
||||||
|
await authApi.logout(auth.refresh).catch(() => {});
|
||||||
|
}
|
||||||
|
auth.clear();
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
|
||||||
|
<header
|
||||||
|
class="border-border bg-chrome flex shrink-0 items-center justify-between gap-3 border-b px-3 py-2.5 sm:px-4"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5 lg:hidden"
|
||||||
|
aria-label="Альбомы"
|
||||||
|
title="Альбомы"
|
||||||
|
onclick={() => (railOpen = true)}
|
||||||
|
>
|
||||||
|
<Icon name="menu" />
|
||||||
|
</button>
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 truncate text-lg font-semibold">
|
||||||
|
<Icon name="photo_camera" />
|
||||||
|
Galo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
|
{#if panel}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5 xl:hidden"
|
||||||
|
aria-label="Панель"
|
||||||
|
title="Панель"
|
||||||
|
onclick={() => (panelOpen = true)}
|
||||||
|
>
|
||||||
|
<Icon name="wallpaper" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if auth.isAuthenticated}
|
||||||
|
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
||||||
|
onclick={logout}
|
||||||
|
>
|
||||||
|
<Icon name="logout" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Выйти</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Войти</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="relative flex min-h-0 flex-1">
|
||||||
|
{#if railOpen}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
aria-label="Закрыть панель альбомов"
|
||||||
|
onclick={() => (railOpen = false)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
<aside
|
||||||
|
class="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 -translate-x-full overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen
|
||||||
|
? 'translate-x-0'
|
||||||
|
: ''}"
|
||||||
|
style="--rail-w: {railWidth}px"
|
||||||
|
aria-label="Альбомы"
|
||||||
|
>
|
||||||
|
{#if rail}{@render rail()}{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="{handle} lg:block"
|
||||||
|
aria-label="Изменить ширину панели альбомов"
|
||||||
|
title="Потяните, чтобы изменить ширину"
|
||||||
|
onpointerdown={(e) => startResize('left', e)}
|
||||||
|
onkeydown={(e) => nudge('left', e)}
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if panel}
|
||||||
|
{#if panelOpen}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fixed inset-0 z-30 bg-black/50 xl:hidden"
|
||||||
|
aria-label="Закрыть панель"
|
||||||
|
onclick={() => (panelOpen = false)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="{handle} xl:block"
|
||||||
|
aria-label="Изменить ширину боковой панели"
|
||||||
|
title="Потяните, чтобы изменить ширину"
|
||||||
|
onpointerdown={(e) => startResize('right', e)}
|
||||||
|
onkeydown={(e) => nudge('right', e)}
|
||||||
|
></button>
|
||||||
|
<aside
|
||||||
|
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 translate-x-full overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen
|
||||||
|
? 'translate-x-0'
|
||||||
|
: ''}"
|
||||||
|
style="--panel-w: {panelWidth}px"
|
||||||
|
aria-label="Панель"
|
||||||
|
>
|
||||||
|
{@render panel()}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -75,18 +75,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if auth.isAuthenticated}
|
{#if auth.isAuthenticated}
|
||||||
<form class="mt-2 flex gap-2" onsubmit={submit}>
|
<form class="relative mt-2" onsubmit={submit}>
|
||||||
<input
|
<input
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
placeholder="Комментарий…"
|
placeholder="Комментарий…"
|
||||||
class="border-border bg-bg text-text flex-1 rounded-lg border px-3 py-1.5 text-sm"
|
class="border-border bg-bg text-text w-full rounded-lg border py-1.5 pr-10 pl-3 text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
|
class="text-primary hover:bg-bg-muted absolute top-1/2 right-1 inline-flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 disabled:opacity-50"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
aria-label="Отправить"
|
||||||
|
title="Отправить"
|
||||||
|
disabled={!text.trim()}
|
||||||
>
|
>
|
||||||
<Icon name="send" class="size-4" />
|
<Icon name="send" class="size-4" />
|
||||||
Отправить
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
albumId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelected?: (album: Album) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { albumId, onClose, onSelected }: Props = $props();
|
||||||
|
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`)
|
||||||
|
.then((data) => (photos = data.photos))
|
||||||
|
.catch((e) => (error = (e as Error).message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function choose(photo: Photo) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const album = await albums.setCover(albumId, photo.id);
|
||||||
|
onSelected?.(album);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Выбор обложки"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h2 class="font-semibold">Выбрать обложку</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
title="Закрыть"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted text-sm">Загрузка…</p>
|
||||||
|
{:else if photos.length === 0}
|
||||||
|
<p class="text-text-muted text-sm">В альбоме нет фотографий.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5">
|
||||||
|
{#each photos as photo (photo.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
class="border-border bg-bg-muted block aspect-square w-full overflow-hidden rounded-lg border hover:border-primary disabled:opacity-50"
|
||||||
|
onclick={() => choose(photo)}
|
||||||
|
>
|
||||||
|
{#if photo.thumb_url}
|
||||||
|
<img
|
||||||
|
src={photo.thumb_url}
|
||||||
|
alt={photo.original_filename}
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
||||||
|
обработка…
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated?: (album: Album) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onClose, onCreated }: Props = $props();
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function submit(ev: Event) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!title.trim() || busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const album = await albums.create(title.trim());
|
||||||
|
onCreated?.(album);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function autofocus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Новый альбом"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
|
||||||
|
onsubmit={submit}
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold">Новый альбом</h2>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-text-muted mb-1 block text-sm">Название</span>
|
||||||
|
<input
|
||||||
|
bind:value={title}
|
||||||
|
use:autofocus
|
||||||
|
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
||||||
|
placeholder="Например, Отпуск"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !title.trim()}
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Icon name="add" class="size-4" />
|
||||||
|
{busy ? 'Создание…' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="grid_view" class="text-text-subtle size-4" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="8"
|
||||||
|
step="1"
|
||||||
|
value={density.cols}
|
||||||
|
oninput={(e) => density.set(Number(e.currentTarget.value))}
|
||||||
|
class="accent-primary h-1 w-24 cursor-pointer"
|
||||||
|
aria-label="Размер миниатюр"
|
||||||
|
title="Размер миниатюр"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import type { Photo } from '$lib/types';
|
|
||||||
import Comments from './Comments.svelte';
|
|
||||||
import Icon from './Icon.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
photos: Photo[];
|
|
||||||
index: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onIndexChange: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { photos, index, onClose, onIndexChange }: Props = $props();
|
|
||||||
|
|
||||||
let fresh = $state<Photo | null>(null);
|
|
||||||
let touchX = 0;
|
|
||||||
|
|
||||||
const current = $derived(photos[index]);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = current?.id;
|
|
||||||
fresh = null;
|
|
||||||
if (!id) return;
|
|
||||||
api<{ photo: Photo }>(`/photos/${id}`)
|
|
||||||
.then((data) => (fresh = data.photo))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
function prev() {
|
|
||||||
if (index > 0) onIndexChange(index - 1);
|
|
||||||
}
|
|
||||||
function next() {
|
|
||||||
if (index < photos.length - 1) onIndexChange(index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(ev: KeyboardEvent) {
|
|
||||||
if (ev.key === 'Escape') onClose();
|
|
||||||
if (ev.key === 'ArrowLeft') prev();
|
|
||||||
if (ev.key === 'ArrowRight') next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart(ev: TouchEvent) {
|
|
||||||
touchX = ev.changedTouches[0].clientX;
|
|
||||||
}
|
|
||||||
function onTouchEnd(ev: TouchEvent) {
|
|
||||||
const dx = ev.changedTouches[0].clientX - touchX;
|
|
||||||
if (dx > 50) prev();
|
|
||||||
else if (dx < -50) next();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
onkeydown={onKey}
|
|
||||||
ontouchstart={onTouchStart}
|
|
||||||
ontouchend={onTouchEnd}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between p-3">
|
|
||||||
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{#if fresh?.download_url}
|
|
||||||
<a
|
|
||||||
href={fresh.download_url}
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Icon name="download" class="size-4" />
|
|
||||||
Скачать
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onClose}
|
|
||||||
aria-label="Закрыть"
|
|
||||||
title="Закрыть"
|
|
||||||
class="border-border text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
|
|
||||||
>
|
|
||||||
<Icon name="close" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 items-center justify-center overflow-hidden px-2 pb-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center px-2"
|
|
||||||
aria-label="Предыдущее фото"
|
|
||||||
title="Предыдущее фото"
|
|
||||||
onclick={prev}
|
|
||||||
disabled={index === 0}
|
|
||||||
>
|
|
||||||
<Icon name="chevron_left" class="size-8" />
|
|
||||||
</button>
|
|
||||||
{#if current}
|
|
||||||
<img
|
|
||||||
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
|
|
||||||
alt={current.original_filename}
|
|
||||||
class="max-h-[75vh] max-w-full rounded-lg object-contain"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center px-2"
|
|
||||||
aria-label="Следующее фото"
|
|
||||||
title="Следующее фото"
|
|
||||||
onclick={next}
|
|
||||||
disabled={index === photos.length - 1}
|
|
||||||
>
|
|
||||||
<Icon name="chevron_right" class="size-8" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if current}
|
|
||||||
<div class="border-border bg-bg-elevated max-h-56 overflow-y-auto border-t p-3">
|
|
||||||
<p class="text-text text-sm">
|
|
||||||
{current.uploader.name} · {current.original_filename}
|
|
||||||
</p>
|
|
||||||
<Comments photoId={current.id} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,7 +14,10 @@
|
|||||||
{#if photos.length === 0}
|
{#if photos.length === 0}
|
||||||
<p class="text-text-muted p-8 text-center">Пока нет фотографий.</p>
|
<p class="text-text-muted p-8 text-center">Пока нет фотографий.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
<ul
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||||
|
>
|
||||||
{#each photos as photo, i (photo.id)}
|
{#each photos as photo, i (photo.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import Icon from './Icon.svelte';
|
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,39 +9,23 @@
|
|||||||
|
|
||||||
let { albumId, onUploaded }: Props = $props();
|
let { albumId, onUploaded }: Props = $props();
|
||||||
|
|
||||||
let dragging = $state(false);
|
let fileInput: HTMLInputElement | undefined = $state();
|
||||||
let progress = $state(0);
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let progress = $state(0);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
function pick() {
|
export function pick() {
|
||||||
fileInput?.click();
|
fileInput?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileInput: HTMLInputElement | undefined = $state();
|
export function upload(files: FileList | File[]) {
|
||||||
|
const list = Array.from(files);
|
||||||
function onDrop(ev: DragEvent) {
|
if (list.length === 0) return;
|
||||||
ev.preventDefault();
|
|
||||||
dragging = false;
|
|
||||||
if (ev.dataTransfer?.files?.length) {
|
|
||||||
upload(ev.dataTransfer.files);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(ev: Event) {
|
|
||||||
const target = ev.target as HTMLInputElement;
|
|
||||||
if (target.files?.length) upload(target.files);
|
|
||||||
target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function upload(files: FileList) {
|
|
||||||
error = '';
|
error = '';
|
||||||
busy = true;
|
busy = true;
|
||||||
progress = 0;
|
progress = 0;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const file of Array.from(files)) {
|
for (const file of list) form.append('files', file);
|
||||||
form.append('files', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', `/api/albums/${albumId}/photos`);
|
xhr.open('POST', `/api/albums/${albumId}/photos`);
|
||||||
@@ -53,6 +36,7 @@
|
|||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
busy = false;
|
busy = false;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
progress = 0;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(xhr.responseText);
|
const data = JSON.parse(xhr.responseText);
|
||||||
onUploaded?.(data.photos ?? []);
|
onUploaded?.(data.photos ?? []);
|
||||||
@@ -73,48 +57,40 @@
|
|||||||
};
|
};
|
||||||
xhr.send(form);
|
xhr.send(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onChange(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement;
|
||||||
|
if (input.files?.length) upload(input.files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<input
|
||||||
class="border-border rounded-xl border-2 border-dashed p-6 text-center transition-colors {dragging
|
|
||||||
? 'bg-bg-muted border-primary'
|
|
||||||
: 'bg-bg-elevated'}"
|
|
||||||
ondragover={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragging = true;
|
|
||||||
}}
|
|
||||||
ondragleave={() => (dragging = false)}
|
|
||||||
ondrop={onDrop}
|
|
||||||
ondragenter={(e) => e.preventDefault()}
|
|
||||||
role="region"
|
|
||||||
aria-label="Загрузка фотографий"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onchange={onChange}
|
onchange={onChange}
|
||||||
/>
|
/>
|
||||||
<Icon name="cloud_upload" class="text-text-subtle mx-auto mb-3 size-10" />
|
|
||||||
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={pick}
|
|
||||||
disabled={busy}
|
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Icon name="add_photo_alternate" class="size-4" />
|
|
||||||
{busy ? 'Загрузка…' : 'Выбрать файлы'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<div class="bg-bg-muted mt-4 h-2 w-full overflow-hidden rounded">
|
<div
|
||||||
|
class="bg-bg-elevated border-border fixed right-4 bottom-4 z-50 w-64 rounded-lg border p-3 shadow-lg"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p class="text-text mb-2 text-sm">Загрузка… {progress}%</p>
|
||||||
|
<div class="bg-bg-muted h-1.5 w-full overflow-hidden rounded">
|
||||||
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
|
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
{#if error}
|
{/if}
|
||||||
<p class="text-danger mt-3 text-sm">{error}</p>
|
{#if error}
|
||||||
{/if}
|
<div
|
||||||
</div>
|
class="bg-bg-elevated border-danger text-danger fixed right-4 bottom-4 z-50 rounded-lg border p-3 text-sm"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photos: Photo[];
|
||||||
|
index: number;
|
||||||
|
canEdit?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onIndexChange: (index: number) => void;
|
||||||
|
onMakeCover?: (photo: Photo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { photos, index, canEdit = false, onClose, onIndexChange, onMakeCover }: Props = $props();
|
||||||
|
|
||||||
|
let fresh = $state<Photo | null>(null);
|
||||||
|
let touchX = 0;
|
||||||
|
|
||||||
|
const current = $derived(photos[index]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = current?.id;
|
||||||
|
fresh = null;
|
||||||
|
if (!id) return;
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
|
.then((data) => (fresh = data.photo))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (index > 0) onIndexChange(index - 1);
|
||||||
|
}
|
||||||
|
function next() {
|
||||||
|
if (index < photos.length - 1) onIndexChange(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') onClose();
|
||||||
|
else if (ev.key === 'ArrowLeft') prev();
|
||||||
|
else if (ev.key === 'ArrowRight') next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(ev: TouchEvent) {
|
||||||
|
touchX = ev.changedTouches[0].clientX;
|
||||||
|
}
|
||||||
|
function onTouchEnd(ev: TouchEvent) {
|
||||||
|
const dx = ev.changedTouches[0].clientX - touchX;
|
||||||
|
if (dx > 50) prev();
|
||||||
|
else if (dx < -50) next();
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="border-border flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
|
||||||
|
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={btn}
|
||||||
|
title="Сделать обложкой"
|
||||||
|
onclick={() => current && onMakeCover?.(current)}
|
||||||
|
>
|
||||||
|
<Icon name="wallpaper" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Обложка</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if fresh?.download_url}
|
||||||
|
<a href={fresh.download_url} class={btn} target="_blank" rel="noreferrer">
|
||||||
|
<Icon name="download" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Скачать</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class={btn} aria-label="Закрыть" title="Закрыть" onclick={onClose}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-canvas flex min-h-0 flex-1 items-center justify-center overflow-hidden px-2 py-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="Просмотр фото"
|
||||||
|
ontouchstart={onTouchStart}
|
||||||
|
ontouchend={onTouchEnd}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center px-1 disabled:opacity-30"
|
||||||
|
aria-label="Предыдущее фото"
|
||||||
|
title="Предыдущее фото"
|
||||||
|
onclick={prev}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<Icon name="chevron_left" class="size-8" />
|
||||||
|
</button>
|
||||||
|
{#if current}
|
||||||
|
<img
|
||||||
|
src={fresh?.preview_url ??
|
||||||
|
fresh?.original_url ??
|
||||||
|
current.preview_url ??
|
||||||
|
current.original_url}
|
||||||
|
alt={current.original_filename}
|
||||||
|
class="max-h-full max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center px-1 disabled:opacity-30"
|
||||||
|
aria-label="Следующее фото"
|
||||||
|
title="Следующее фото"
|
||||||
|
onclick={next}
|
||||||
|
disabled={index === photos.length - 1}
|
||||||
|
>
|
||||||
|
<Icon name="chevron_right" class="size-8" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-border flex shrink-0 gap-2 overflow-x-auto border-t p-2" aria-label="Лента миниатюр">
|
||||||
|
{#each photos as photo, i (photo.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-16 w-16 shrink-0 overflow-hidden rounded border {i === index
|
||||||
|
? 'border-primary'
|
||||||
|
: 'border-border'}"
|
||||||
|
onclick={() => onIndexChange(i)}
|
||||||
|
aria-label="Фото {i + 1}"
|
||||||
|
aria-current={i === index ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{#if photo.thumb_url}
|
||||||
|
<img
|
||||||
|
src={photo.thumb_url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="bg-bg-muted block h-full w-full"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -40,7 +40,10 @@ export const icons = {
|
|||||||
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
|
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
|
||||||
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
|
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
|
||||||
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
|
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
|
||||||
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' }
|
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' },
|
||||||
|
menu: { path: '<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>' },
|
||||||
|
grid_view: { path: '<path fill="currentColor" d="M3 3v8h8V3zm10 0v8h8V3zM3 13v8h8v-8zm10 0v8h8v-8z"/>' },
|
||||||
|
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' }
|
||||||
} satisfies Record<string, IconDef>;
|
} satisfies Record<string, IconDef>;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { Album } from '$lib/types';
|
||||||
|
import { auth } from './auth.svelte';
|
||||||
|
|
||||||
|
class AlbumsStore {
|
||||||
|
list = $state<Album[]>([]);
|
||||||
|
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<Album> {
|
||||||
|
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<Album> {
|
||||||
|
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();
|
||||||
@@ -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();
|
||||||
@@ -14,13 +14,13 @@ function resolve(theme: Theme): Resolved {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ThemeStore {
|
class ThemeStore {
|
||||||
current = $state<Theme>('system');
|
current = $state<Theme>('dark');
|
||||||
resolved = $state<Resolved>('light');
|
resolved = $state<Resolved>('dark');
|
||||||
private media: MediaQueryList | null = null;
|
private media: MediaQueryList | null = null;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (typeof window === 'undefined') return;
|
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.current = stored;
|
||||||
this.apply();
|
this.apply();
|
||||||
this.media = window.matchMedia('(prefers-color-scheme: dark)');
|
this.media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface Album {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
cover_photo_id?: string | null;
|
cover_photo_id?: string | null;
|
||||||
|
cover_thumb_url?: string;
|
||||||
|
photos_count?: number;
|
||||||
|
members_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
|
import AlbumRail from '$lib/components/AlbumRail.svelte';
|
||||||
|
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AppShell>
|
||||||
|
{#snippet rail()}
|
||||||
|
<AlbumRail />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet panel()}
|
||||||
|
<AlbumPanel />
|
||||||
|
{/snippet}
|
||||||
|
{@render children()}
|
||||||
|
</AppShell>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !auth.isAuthenticated}
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
|
||||||
|
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-5" />
|
||||||
|
Войти
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-4 sm:p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<h1 class="text-xl font-semibold">Мои альбомы</h1>
|
||||||
|
<DensityControl />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if albums.loading && albums.list.length === 0}
|
||||||
|
<p class="text-text-muted">Загрузка…</p>
|
||||||
|
{:else if albums.list.length === 0}
|
||||||
|
<div class="text-text-muted flex flex-col items-center gap-3 py-16">
|
||||||
|
<Icon name="photo_library" class="size-16 text-text-subtle" />
|
||||||
|
<p>Альбомов пока нет. Создайте первый кнопкой <span class="text-text">+</span> в панели альбомов.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul
|
||||||
|
class="grid gap-3"
|
||||||
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||||
|
>
|
||||||
|
{#each albums.list as album (album.id)}
|
||||||
|
<li>
|
||||||
|
<a href="/albums/{album.id}" class="group block">
|
||||||
|
<div
|
||||||
|
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
|
||||||
|
>
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img
|
||||||
|
src={album.cover_thumb_url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="flex h-full w-full items-center justify-center">
|
||||||
|
<Icon name="image" class="size-12 text-text-subtle" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<h2 class="text-text truncate text-sm font-medium">{album.title}</h2>
|
||||||
|
<p class="text-text-subtle text-xs">{album.photos_count ?? 0} фото</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
|
import Uploader from '$lib/components/Uploader.svelte';
|
||||||
|
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
|
let album = $state<Album | null>(null);
|
||||||
|
let role = $state('');
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let nextCursor = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let dragging = $state(false);
|
||||||
|
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
||||||
|
|
||||||
|
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
||||||
|
|
||||||
|
async function loadAlbum() {
|
||||||
|
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
|
||||||
|
album = data.album;
|
||||||
|
role = data.role;
|
||||||
|
albums.upsert(data.album);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos() {
|
||||||
|
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
||||||
|
`/albums/${albumId}/photos`
|
||||||
|
);
|
||||||
|
photos = data.photos;
|
||||||
|
nextCursor = data.next_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!nextCursor) return;
|
||||||
|
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
||||||
|
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
|
||||||
|
);
|
||||||
|
photos = [...photos, ...data.photos];
|
||||||
|
nextCursor = data.next_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
Promise.all([loadAlbum(), loadPhotos()])
|
||||||
|
.catch((e) => (error = (e as Error).message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
let client: SSEClient | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
if (!auth.access) return;
|
||||||
|
client = new SSEClient({
|
||||||
|
path: `/api/sse/albums/${id}`,
|
||||||
|
onEvent: handleEvent,
|
||||||
|
onReconnect: () => loadPhotos()
|
||||||
|
});
|
||||||
|
client.connect(auth.access);
|
||||||
|
return () => {
|
||||||
|
client?.close();
|
||||||
|
client = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEvent(ev: SSEEvent) {
|
||||||
|
const data = ev.data as Record<string, unknown>;
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'photo.created': {
|
||||||
|
const photo = data as unknown as Photo;
|
||||||
|
if (photos.some((p) => p.id === photo.id)) {
|
||||||
|
photos = photos.map((p) => (p.id === photo.id ? photo : p));
|
||||||
|
} else {
|
||||||
|
photos = [photo, ...photos];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'photo.deleted': {
|
||||||
|
photos = photos.filter((p) => p.id !== data.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'like.added': {
|
||||||
|
photos = photos.map((p) =>
|
||||||
|
p.id === data.photo_id
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
likes_count: p.likes_count + 1,
|
||||||
|
liked_by_me: data.user_id === auth.user?.id || p.liked_by_me
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'like.removed': {
|
||||||
|
photos = photos.map((p) =>
|
||||||
|
p.id === data.photo_id
|
||||||
|
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'comment.created': {
|
||||||
|
const comment = data as unknown as { photo_id: string };
|
||||||
|
photos = photos.map((p) =>
|
||||||
|
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'album.updated': {
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
if (canUpload && ev.dataTransfer?.files?.length) {
|
||||||
|
uploader?.upload(ev.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted p-4">Загрузка…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-danger p-4">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="relative flex min-h-full flex-col"
|
||||||
|
role="region"
|
||||||
|
aria-label="Альбом"
|
||||||
|
ondragover={(e) => {
|
||||||
|
if (!canUpload) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) dragging = false;
|
||||||
|
}}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="border-border bg-chrome sticky top-0 z-10 flex items-center justify-between gap-3 border-b px-3 py-2"
|
||||||
|
>
|
||||||
|
<h1 class="text-text truncate text-sm font-medium">{album?.title ?? ''}</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<DensityControl />
|
||||||
|
{#if canUpload}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5"
|
||||||
|
aria-label="Загрузить фото"
|
||||||
|
title="Загрузить фото"
|
||||||
|
onclick={() => uploader?.pick()}
|
||||||
|
>
|
||||||
|
<Icon name="cloud_upload" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-3 sm:p-4">
|
||||||
|
{#if canUpload && photos.length === 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text-muted hover:border-primary flex w-full flex-col items-center gap-3 rounded-xl border-2 border-dashed p-12 text-center"
|
||||||
|
onclick={() => uploader?.pick()}
|
||||||
|
>
|
||||||
|
<Icon name="cloud_upload" class="size-10 text-text-subtle" />
|
||||||
|
<span>Перетащите фото сюда или выберите файлы</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<PhotoGrid
|
||||||
|
{photos}
|
||||||
|
onOpen={(i) => {
|
||||||
|
const p = photos[i];
|
||||||
|
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if nextCursor}
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={loadMore}
|
||||||
|
>
|
||||||
|
<Icon name="expand_more" class="size-4" />
|
||||||
|
Показать ещё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if dragging}
|
||||||
|
<div
|
||||||
|
class="bg-bg/80 border-primary pointer-events-none fixed inset-3 z-40 flex items-center justify-center rounded-xl border-2 border-dashed"
|
||||||
|
>
|
||||||
|
<span class="text-text inline-flex items-center gap-2 text-lg">
|
||||||
|
<Icon name="cloud_upload" class="size-6" />
|
||||||
|
Отпустите, чтобы загрузить
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Uploader
|
||||||
|
bind:this={uploader}
|
||||||
|
{albumId}
|
||||||
|
onUploaded={(created) => {
|
||||||
|
for (const p of created) {
|
||||||
|
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Viewer from '$lib/components/Viewer.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
const photoId = $derived($page.params.photoId ?? '');
|
||||||
|
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let role = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let saved = $state(false);
|
||||||
|
|
||||||
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
|
const index = $derived(photos.findIndex((p) => p.id === photoId));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
Promise.all([
|
||||||
|
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
|
||||||
|
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||||
|
])
|
||||||
|
.then(([p, a]) => {
|
||||||
|
photos = p.photos;
|
||||||
|
role = a.role;
|
||||||
|
})
|
||||||
|
.catch((e) => (error = (e as Error).message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
goto(`/albums/${albumId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(i: number) {
|
||||||
|
const p = photos[i];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted p-4">Загрузка…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-danger p-4">{error}</p>
|
||||||
|
{:else if index < 0}
|
||||||
|
<div class="text-text-muted p-6 text-center">
|
||||||
|
<p class="mb-3">Фото не найдено.</p>
|
||||||
|
<a href="/albums/{albumId}" class="text-primary">К альбому</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
{#if saved}
|
||||||
|
<p class="bg-success/10 text-success px-3 py-1 text-center text-xs">Обложка обновлена</p>
|
||||||
|
{/if}
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<Viewer
|
||||||
|
{photos}
|
||||||
|
{index}
|
||||||
|
{canEdit}
|
||||||
|
onClose={close}
|
||||||
|
onIndexChange={change}
|
||||||
|
onMakeCover={makeCover}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-canvas text-text min-h-screen">
|
||||||
|
<header class="border-border bg-chrome border-b">
|
||||||
|
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon name="photo_camera" />
|
||||||
|
Galo
|
||||||
|
</a>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
+7
-34
@@ -3,6 +3,7 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
|
import Viewer from '$lib/components/Viewer.svelte';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
const token = $derived($page.params.token);
|
const token = $derived($page.params.token);
|
||||||
@@ -65,41 +66,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active >= 0}
|
{#if active >= 0}
|
||||||
<div
|
<div class="bg-canvas fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label="Просмотр">
|
||||||
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center"
|
<Viewer
|
||||||
role="dialog"
|
{photos}
|
||||||
tabindex="-1"
|
index={active}
|
||||||
onkeydown={(e) => {
|
onClose={() => (active = -1)}
|
||||||
if (e.key === 'Escape') active = -1;
|
onIndexChange={(i) => (active = i)}
|
||||||
}}
|
|
||||||
onclick={() => (active = -1)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={photos[active].preview_url ?? photos[active].original_url}
|
|
||||||
alt={photos[active].original_filename}
|
|
||||||
class="max-h-[90vh] max-w-full object-contain"
|
|
||||||
/>
|
/>
|
||||||
<div class="absolute top-4 right-4 flex gap-2">
|
|
||||||
<a
|
|
||||||
href={photos[active].download_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
class="border-border bg-bg-elevated text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Icon name="download" class="size-4" />
|
|
||||||
Скачать
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
title="Закрыть"
|
|
||||||
class="border-border bg-bg-elevated text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
|
|
||||||
onclick={() => (active = -1)}
|
|
||||||
>
|
|
||||||
<Icon name="close" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import IconSprite from '$lib/components/IconSprite.svelte';
|
import IconSprite from '$lib/components/IconSprite.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { theme } from '$lib/stores/theme.svelte';
|
import { theme } from '$lib/stores/theme.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import { authApi } from '$lib/api';
|
import { authApi } from '$lib/api';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
theme.init();
|
theme.init();
|
||||||
|
density.init();
|
||||||
if (auth.isAuthenticated && !auth.user) {
|
if (auth.isAuthenticated && !auth.user) {
|
||||||
authApi
|
authApi
|
||||||
.me()
|
.me()
|
||||||
@@ -20,49 +19,7 @@
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
if (auth.refresh) {
|
|
||||||
await authApi.logout(auth.refresh).catch(() => {});
|
|
||||||
}
|
|
||||||
auth.clear();
|
|
||||||
goto('/login');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
<div class="bg-bg text-text min-h-screen">
|
{@render children()}
|
||||||
<header class="border-border bg-bg-elevated border-b">
|
|
||||||
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
|
||||||
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<Icon name="photo_camera" />
|
|
||||||
Galo
|
|
||||||
</a>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
{#if auth.isAuthenticated}
|
|
||||||
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
|
||||||
onclick={logout}
|
|
||||||
>
|
|
||||||
<Icon name="logout" class="size-4" />
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<Icon name="login" class="size-4" />
|
|
||||||
Войти
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
|
||||||
{@render children()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
|
||||||
import type { Album } from '$lib/types';
|
|
||||||
|
|
||||||
let albums = $state<Album[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let title = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
if (!auth.isAuthenticated) {
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await api<{ albums: Album[] }>('/albums');
|
|
||||||
albums = data.albums;
|
|
||||||
} catch (e) {
|
|
||||||
error = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function create(ev: Event) {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!title.trim()) return;
|
|
||||||
try {
|
|
||||||
const data = await api<{ album: Album }>('/albums', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title }
|
|
||||||
});
|
|
||||||
albums = [data.album, ...albums];
|
|
||||||
title = '';
|
|
||||||
} catch (e) {
|
|
||||||
error = (e as Error).message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !auth.isAuthenticated}
|
|
||||||
<div class="py-16 text-center">
|
|
||||||
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
|
|
||||||
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
|
||||||
>
|
|
||||||
<Icon name="login" class="size-5" />
|
|
||||||
Войти
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
|
||||||
<h1 class="text-2xl font-bold">Мои альбомы</h1>
|
|
||||||
<form class="flex gap-2" onsubmit={create}>
|
|
||||||
<input
|
|
||||||
bind:value={title}
|
|
||||||
placeholder="Название альбома"
|
|
||||||
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
<Icon name="add" class="size-4" />
|
|
||||||
Создать
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="text-text-muted">Загрузка…</p>
|
|
||||||
{:else if albums.length === 0}
|
|
||||||
<div class="text-text-muted flex flex-col items-center gap-3 py-12">
|
|
||||||
<Icon name="photo_library" class="size-16 text-text-subtle" />
|
|
||||||
<p>Альбомов пока нет. Создайте первый.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each albums as album (album.id)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/albums/{album.id}"
|
|
||||||
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
|
|
||||||
>
|
|
||||||
<div class="bg-bg-muted flex aspect-video items-center justify-center">
|
|
||||||
<Icon name="image" class="size-16 text-text-subtle" />
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<h2 class="text-text font-semibold">{album.title}</h2>
|
|
||||||
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
|
||||||
import Lightbox from '$lib/components/Lightbox.svelte';
|
|
||||||
import Uploader from '$lib/components/Uploader.svelte';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
|
||||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
|
||||||
import type { Album, Photo } from '$lib/types';
|
|
||||||
|
|
||||||
const albumId = $derived($page.params.id ?? '');
|
|
||||||
|
|
||||||
let album = $state<Album | null>(null);
|
|
||||||
let role = $state('');
|
|
||||||
let photos = $state<Photo[]>([]);
|
|
||||||
let nextCursor = $state('');
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let lightboxIndex = $state(-1);
|
|
||||||
|
|
||||||
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
|
||||||
|
|
||||||
async function loadAlbum() {
|
|
||||||
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
|
|
||||||
album = data.album;
|
|
||||||
role = data.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPhotos() {
|
|
||||||
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
|
||||||
`/albums/${albumId}/photos`
|
|
||||||
);
|
|
||||||
photos = data.photos;
|
|
||||||
nextCursor = data.next_cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (!nextCursor) return;
|
|
||||||
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
|
||||||
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
|
|
||||||
);
|
|
||||||
photos = [...photos, ...data.photos];
|
|
||||||
nextCursor = data.next_cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = albumId;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
Promise.all([loadAlbum(), loadPhotos()])
|
|
||||||
.catch((e) => (error = (e as Error).message))
|
|
||||||
.finally(() => (loading = false));
|
|
||||||
});
|
|
||||||
|
|
||||||
let client: SSEClient | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = albumId;
|
|
||||||
if (!auth.access) return;
|
|
||||||
client = new SSEClient({
|
|
||||||
path: `/api/sse/albums/${id}`,
|
|
||||||
onEvent: handleEvent,
|
|
||||||
onReconnect: () => loadPhotos()
|
|
||||||
});
|
|
||||||
client.connect(auth.access);
|
|
||||||
return () => {
|
|
||||||
client?.close();
|
|
||||||
client = null;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleEvent(ev: SSEEvent) {
|
|
||||||
const data = ev.data as Record<string, unknown>;
|
|
||||||
switch (ev.type) {
|
|
||||||
case 'photo.created': {
|
|
||||||
const photo = data as unknown as Photo;
|
|
||||||
if (photos.some((p) => p.id === photo.id)) {
|
|
||||||
photos = photos.map((p) => (p.id === photo.id ? photo : p));
|
|
||||||
} else {
|
|
||||||
photos = [photo, ...photos];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'photo.deleted': {
|
|
||||||
photos = photos.filter((p) => p.id !== data.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'like.added': {
|
|
||||||
photos = photos.map((p) =>
|
|
||||||
p.id === data.photo_id
|
|
||||||
? { ...p, likes_count: p.likes_count + 1, liked_by_me: data.user_id === auth.user?.id || p.liked_by_me }
|
|
||||||
: p
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'like.removed': {
|
|
||||||
photos = photos.map((p) =>
|
|
||||||
p.id === data.photo_id
|
|
||||||
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
|
|
||||||
: p
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'comment.created': {
|
|
||||||
const comment = data as unknown as { photo_id: string };
|
|
||||||
photos = photos.map((p) =>
|
|
||||||
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'album.updated': {
|
|
||||||
loadAlbum().catch(() => {});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="text-text-muted">Загрузка…</p>
|
|
||||||
{:else if error}
|
|
||||||
<p class="text-danger">{error}</p>
|
|
||||||
{:else if album}
|
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">{album.title}</h1>
|
|
||||||
<p class="text-text-muted">{album.description}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<a
|
|
||||||
href="/albums/{albumId}/members"
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Icon name="group" class="size-4" />
|
|
||||||
Участники
|
|
||||||
</a>
|
|
||||||
{#if role === 'owner' || role === 'admin'}
|
|
||||||
<a
|
|
||||||
href="/albums/{albumId}/settings"
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Icon name="settings" class="size-4" />
|
|
||||||
Настройки
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if canUpload}
|
|
||||||
<div class="mb-6">
|
|
||||||
<Uploader
|
|
||||||
{albumId}
|
|
||||||
onUploaded={(created) => {
|
|
||||||
for (const p of created) {
|
|
||||||
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<PhotoGrid {photos} onOpen={(i) => (lightboxIndex = i)} />
|
|
||||||
|
|
||||||
{#if nextCursor}
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
|
||||||
onclick={loadMore}
|
|
||||||
>
|
|
||||||
<Icon name="expand_more" class="size-4" />
|
|
||||||
Показать ещё
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if lightboxIndex >= 0}
|
|
||||||
<Lightbox
|
|
||||||
{photos}
|
|
||||||
index={lightboxIndex}
|
|
||||||
onClose={() => (lightboxIndex = -1)}
|
|
||||||
onIndexChange={(i) => (lightboxIndex = i)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -11,6 +11,8 @@ export default {
|
|||||||
muted: 'hsl(var(--bg-muted) / <alpha-value>)'
|
muted: 'hsl(var(--bg-muted) / <alpha-value>)'
|
||||||
},
|
},
|
||||||
border: 'hsl(var(--border) / <alpha-value>)',
|
border: 'hsl(var(--border) / <alpha-value>)',
|
||||||
|
chrome: 'hsl(var(--chrome) / <alpha-value>)',
|
||||||
|
canvas: 'hsl(var(--canvas) / <alpha-value>)',
|
||||||
text: {
|
text: {
|
||||||
DEFAULT: 'hsl(var(--text) / <alpha-value>)',
|
DEFAULT: 'hsl(var(--text) / <alpha-value>)',
|
||||||
muted: 'hsl(var(--text-muted) / <alpha-value>)',
|
muted: 'hsl(var(--text-muted) / <alpha-value>)',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-10
|
||||||
@@ -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` или кастомный тултип — косметика, спеки не меняет.
|
||||||
@@ -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 нет.
|
||||||
@@ -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** система отклоняет запрос
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Обложка по умолчанию
|
||||||
|
При обработке загруженного фото система SHALL назначать его обложкой альбома только если
|
||||||
|
обложка ещё не задана.
|
||||||
|
|
||||||
|
#### Scenario: Первое фото
|
||||||
|
- **WHEN** фото обработано, и у альбома нет обложки
|
||||||
|
- **THEN** система назначает это фото обложкой
|
||||||
|
|
||||||
|
#### Scenario: Обложка уже задана
|
||||||
|
- **WHEN** фото обработано, а обложка уже задана
|
||||||
|
- **THEN** система не меняет обложку
|
||||||
@@ -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** органы управления каркаса достижимы, доступные имена присутствуют, фокус виден
|
||||||
@@ -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"`.
|
||||||
Reference in New Issue
Block a user