Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9329ebc023 |
@@ -17,15 +17,20 @@ type MemberLookup interface {
|
||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||
}
|
||||
|
||||
type PhotoLookup interface {
|
||||
PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
members MemberLookup
|
||||
photos PhotoLookup
|
||||
hub *sse.Hub
|
||||
storage *storage.Client
|
||||
}
|
||||
|
||||
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *Service {
|
||||
return &Service{store: store, members: members, hub: hub, storage: storage}
|
||||
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client, photos PhotoLookup) *Service {
|
||||
return &Service{store: store, members: members, photos: photos, hub: hub, storage: storage}
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
@@ -51,7 +56,23 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
return s.store.ListForUser(ctx, userID)
|
||||
list, err := s.store.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range list {
|
||||
s.attachCoverURL(ctx, &list[i])
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachCoverURL(ctx context.Context, a *models.Album) {
|
||||
if a == nil || a.CoverThumbKey == "" || s.storage == nil {
|
||||
return
|
||||
}
|
||||
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
|
||||
a.CoverURL = url
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||
@@ -62,6 +83,7 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
s.attachCoverURL(ctx, a)
|
||||
if a.OwnerID == userID {
|
||||
return a, roles.Owner, nil
|
||||
}
|
||||
@@ -107,11 +129,23 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
||||
if in.ClearCover {
|
||||
a.CoverPhotoID = nil
|
||||
} else if in.CoverPhotoID != nil {
|
||||
ok, err := s.photos.PhotoInAlbum(ctx, *in.CoverPhotoID, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, httpx.BadRequest("invalid_cover", "cover photo must belong to the album")
|
||||
}
|
||||
a.CoverPhotoID = in.CoverPhotoID
|
||||
}
|
||||
if err := s.store.Update(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fresh, err := s.store.GetByID(ctx, albumID); err == nil {
|
||||
a.CoverThumbKey = fresh.CoverThumbKey
|
||||
a.CoverURL = ""
|
||||
s.attachCoverURL(ctx, a)
|
||||
}
|
||||
s.hub.Publish(albumID, "album.updated", a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
|
||||
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
var a models.Album
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
|
||||
FROM albums WHERE id = $1`, id).
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
|
||||
a.created_at, a.updated_at
|
||||
FROM albums a WHERE a.id = $1`, id).
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -57,7 +59,9 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
|
||||
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
|
||||
a.created_at, a.updated_at
|
||||
FROM albums a
|
||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||
@@ -70,7 +74,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
||||
out := []models.Album{}
|
||||
for rows.Next() {
|
||||
var a models.Album
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
@@ -52,14 +52,14 @@ func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hu
|
||||
|
||||
albumsStore := albums.NewStore(pool)
|
||||
membersStore := members.NewStore(pool)
|
||||
photosStore := photos.NewStore(pool)
|
||||
|
||||
notifStore := notifications.NewStore(pool)
|
||||
notifSvc := notifications.NewService(notifStore, hub)
|
||||
|
||||
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
|
||||
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st, photosStore)
|
||||
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
|
||||
|
||||
photosStore := photos.NewStore(pool)
|
||||
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
|
||||
|
||||
socialStore := social.NewStore(pool)
|
||||
|
||||
@@ -31,6 +31,8 @@ type Album struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CoverThumbKey string `json:"-"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.ClearCover(ctx, p.AlbumID, p.ID)
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
|
||||
@@ -123,5 +123,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, albumID, photoID)
|
||||
}
|
||||
|
||||
func (s *Store) ClearCover(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`, albumID, photoID)
|
||||
}
|
||||
|
||||
func (s *Store) PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM photos WHERE id = $1 AND album_id = $2)`, photoID, albumID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/sharelinks"
|
||||
"shared-album/internal/sse"
|
||||
)
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
@@ -187,6 +189,96 @@ func TestMemberRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumCover(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
|
||||
|
||||
albumStore := albums.NewStore(pool)
|
||||
photosStore := photos.NewStore(pool)
|
||||
|
||||
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
|
||||
if err := albumStore.Create(ctx, a); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
p1 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k1", Width: 1, Height: 1}
|
||||
p2 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k2", Width: 1, Height: 1}
|
||||
for _, p := range []*models.Photo{p1, p2} {
|
||||
if err := photosStore.Create(ctx, p); err != nil {
|
||||
t.Fatalf("create photo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
photosStore.SetCover(ctx, a.ID, p1.ID)
|
||||
got, _ := albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
|
||||
t.Fatalf("auto-cover not set: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
photosStore.SetCover(ctx, a.ID, p2.ID)
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
|
||||
t.Fatalf("auto-cover overwrote existing cover: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
photosStore.ClearCover(ctx, a.ID, p2.ID)
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil {
|
||||
t.Fatalf("clear with non-cover photo should be a no-op")
|
||||
}
|
||||
photosStore.ClearCover(ctx, a.ID, p1.ID)
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("cover not cleared: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if ok, err := photosStore.PhotoInAlbum(ctx, p1.ID, a.ID); err != nil || !ok {
|
||||
t.Fatalf("photo should belong to album: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
other := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "B"}
|
||||
if err := albumStore.Create(ctx, other); err != nil {
|
||||
t.Fatalf("create other album: %v", err)
|
||||
}
|
||||
pForeign := &models.Photo{ID: auth.NewID(), AlbumID: other.ID, UploaderID: owner.ID, StorageKey: "k3", Width: 1, Height: 1}
|
||||
if err := photosStore.Create(ctx, pForeign); err != nil {
|
||||
t.Fatalf("create foreign photo: %v", err)
|
||||
}
|
||||
if ok, _ := photosStore.PhotoInAlbum(ctx, pForeign.ID, a.ID); ok {
|
||||
t.Fatalf("foreign photo should not belong to album")
|
||||
}
|
||||
|
||||
svc := albums.NewService(albumStore, members.NewStore(pool), sse.NewHub(16), nil, photosStore)
|
||||
|
||||
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &pForeign.ID}); err == nil {
|
||||
t.Fatalf("expected error when setting foreign photo as cover")
|
||||
}
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("foreign cover should not be applied: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &p2.ID}); err != nil {
|
||||
t.Fatalf("valid cover update: %v", err)
|
||||
}
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != p2.ID {
|
||||
t.Fatalf("valid cover not applied: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{ClearCover: true}); err != nil {
|
||||
t.Fatalf("clear cover via service: %v", err)
|
||||
}
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("cover not cleared via service: %v", got.CoverPhotoID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
class?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { name, class: className = 'size-5', label }: Props = $props();
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
add: 'M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z',
|
||||
close:
|
||||
'M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z'
|
||||
};
|
||||
|
||||
const path = $derived(icons[name] ?? '');
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
focusable="false"
|
||||
class={className}
|
||||
role={label ? 'img' : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : 'true'}
|
||||
>
|
||||
{#if path}<path d={path} />{/if}
|
||||
</svg>
|
||||
@@ -11,6 +11,7 @@ export interface Album {
|
||||
title: string;
|
||||
description: string;
|
||||
cover_photo_id?: string | null;
|
||||
cover_url?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { Album } from '$lib/types';
|
||||
|
||||
let albums = $state<Album[]>([]);
|
||||
let loading = $state(true);
|
||||
let title = $state('');
|
||||
let error = $state('');
|
||||
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||
|
||||
function openDialog() {
|
||||
title = '';
|
||||
error = '';
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!auth.isAuthenticated) {
|
||||
@@ -37,6 +45,7 @@
|
||||
});
|
||||
albums = [data.album, ...albums];
|
||||
title = '';
|
||||
dialogEl?.close();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
@@ -52,16 +61,15 @@
|
||||
{: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 rounded-lg px-4 py-2 text-sm" type="submit"
|
||||
>Создать</button
|
||||
<button
|
||||
type="button"
|
||||
onclick={openDialog}
|
||||
title="Создать альбом"
|
||||
aria-label="Создать альбом"
|
||||
class="bg-primary text-primary-fg rounded-lg p-2"
|
||||
>
|
||||
</form>
|
||||
<Icon name="add" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
||||
@@ -78,7 +86,19 @@
|
||||
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 text-4xl">🖼️</div>
|
||||
{#if album.cover_url}
|
||||
<img
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="bg-bg-muted aspect-video w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-bg-muted flex aspect-video items-center justify-center text-4xl">
|
||||
🖼️
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
@@ -88,4 +108,32 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="bg-bg-elevated text-text border-border m-auto w-full max-w-sm rounded-xl border p-0 backdrop:bg-black/50"
|
||||
>
|
||||
<form class="space-y-4 p-5" onsubmit={create}>
|
||||
<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}
|
||||
placeholder="Название альбома"
|
||||
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
|
||||
onclick={() => dialogEl?.close()}>Отмена</button
|
||||
>
|
||||
<button type="submit" class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm"
|
||||
>Создать</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id);
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
|
||||
let album = $state<Album | null>(null);
|
||||
let role = $state('');
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import type { Album, ShareLink } from '$lib/types';
|
||||
import type { Album, Photo, ShareLink } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id);
|
||||
|
||||
let album = $state<Album | null>(null);
|
||||
let role = $state('');
|
||||
let photos = $state<Photo[]>([]);
|
||||
let shareLinks = $state<ShareLink[]>([]);
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
@@ -16,14 +18,17 @@
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [albumData, linksData] = await Promise.all([
|
||||
api<{ album: Album }>(`/albums/${albumId}`),
|
||||
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`)
|
||||
const [albumData, linksData, photosData] = await Promise.all([
|
||||
api<{ album: Album; role: string }>(`/albums/${albumId}`),
|
||||
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`),
|
||||
api<{ photos: Photo[]; next_cursor: string }>(`/albums/${albumId}/photos`)
|
||||
]);
|
||||
album = albumData.album;
|
||||
role = albumData.role;
|
||||
title = album.title;
|
||||
description = album.description;
|
||||
shareLinks = linksData.share_links;
|
||||
photos = photosData.photos;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
@@ -49,6 +54,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function setCover(photoId: string) {
|
||||
error = '';
|
||||
try {
|
||||
const data = await api<{ album: Album }>(`/albums/${albumId}`, {
|
||||
method: 'PATCH',
|
||||
body: { cover_photo_id: photoId }
|
||||
});
|
||||
album = data.album;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCover() {
|
||||
error = '';
|
||||
try {
|
||||
const data = await api<{ album: Album }>(`/albums/${albumId}`, {
|
||||
method: 'PATCH',
|
||||
body: { clear_cover: true }
|
||||
});
|
||||
album = data.album;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createLink(ev: Event) {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
@@ -107,6 +138,56 @@
|
||||
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
|
||||
</form>
|
||||
|
||||
{#if role === 'owner' || role === 'admin'}
|
||||
<div class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Обложка</h2>
|
||||
{#if album?.cover_photo_id}
|
||||
<button type="button" class="text-danger text-sm" onclick={clearCover}>Убрать</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#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-6">
|
||||
{#each photos as photo (photo.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setCover(photo.id)}
|
||||
title="Сделать обложкой"
|
||||
aria-pressed={album?.cover_photo_id === photo.id}
|
||||
class="group relative block aspect-square w-full overflow-hidden rounded-lg border-2 {album?.cover_photo_id ===
|
||||
photo.id
|
||||
? 'border-primary'
|
||||
: 'border-border'}"
|
||||
>
|
||||
{#if photo.thumb_url}
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
||||
обработка…
|
||||
</span>
|
||||
{/if}
|
||||
{#if album?.cover_photo_id === photo.id}
|
||||
<span class="bg-primary text-primary-fg absolute right-1 bottom-1 rounded px-1.5 py-0.5 text-xs"
|
||||
>✓</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4">
|
||||
<h2 class="font-semibold">Ссылки для просмотра</h2>
|
||||
<form class="flex items-end gap-2" onsubmit={createLink}>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-10
|
||||
@@ -0,0 +1,102 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` для мотивации. Текущее состояние, важное для подхода:
|
||||
|
||||
- Авторизация — Bearer-токен (не cookies), поэтому `<img>` не может обращаться к
|
||||
защищённым эндпоинтам напрямую. Все фото уже отдаются фронтенду как presigned
|
||||
URL (`internal/photos/service.go`, `attachURLs`).
|
||||
- `albums.cover_photo_id` — колонка `text` без внешнего ключа (миграция
|
||||
`000001_init.up.sql:15`), поэтому удаление фото-обложки оставляет висячий id.
|
||||
- `albums.Service` уже держит `*storage.Client` и умеет presign; отдельной
|
||||
зависимости от пакета `photos` нет.
|
||||
- Сборка фото идёт асинхронно (`processVariants`): `thumb_key` появляется не
|
||||
сразу после создания записи фото.
|
||||
- В `backend/static` закоммичен собранный `Icon.svelte` (inline-SVG спрайт
|
||||
Material Symbols), при этом в `frontend/src` компонента нет — UI сейчас на
|
||||
эмодзи. Берём inline-SVG как ориентир.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Список альбомов отдаёт `cover_url` (presigned миниатюра) и карточки его рисуют.
|
||||
- Выбор и очистка обложки из фото альбома в настройках, применяется немедленно.
|
||||
- Автоматическая обложка не затирает ручной выбор.
|
||||
- Кнопка создания — иконка с хинтом, открывающая диалог ввода имени.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Обложка в шапке страницы альбома и в публичных share-страницах.
|
||||
- Смена обложки из лайтбокса/контекстного меню фото (может быть позже).
|
||||
- Переработка всей иконочной системы проекта — вводим минимальный inline-SVG.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. `cover_url` в ответе списка (вариант A)
|
||||
|
||||
`GET /albums` отдаёт каждому альбому `cover_url` — presigned URL миниатюры
|
||||
(`thumb`) или `null`. Источник ключа: `albums.Store` расширяет выборку
|
||||
подзапросом к `photos` за `thumb_key` по `cover_photo_id`; `albums.Service`
|
||||
делает presign через уже имеющийся `storage.Client`.
|
||||
|
||||
**Альтернативы:** (B) фронт дёргает `GET /photos/:id` на каждую обложку — N+1 и
|
||||
тянет лишние поля; (C) эндпоинт `GET /albums/:id/cover` с редиректом — не
|
||||
работает, `<img>` не приложит Bearer-токен. Оба отпадают.
|
||||
|
||||
### 2. Presign в `albums`, а не в `photos`
|
||||
|
||||
Не вводим зависимость `albums -> photos`; достаточно подзапроса за ключом и
|
||||
существующего `storage` в `albums.Service`. Дублирование кода presign
|
||||
минимально и оправдано сохранением слоёв.
|
||||
|
||||
### 3. Авто-обложка только при пустой
|
||||
|
||||
`photos.Store.SetCover` меняет SQL на условный:
|
||||
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`.
|
||||
Вызов остаётся в `processVariants` после `SetVariants`, чтобы ключ миниатюры уже
|
||||
существовал. Это же сохраняет ручной выбор при последующих загрузках.
|
||||
|
||||
### 4. Валидация обложки при PATCH
|
||||
|
||||
`albums.Service.Update` при заданном `cover_photo_id` SHALL проверять, что фото
|
||||
принадлежит этому альбому (иначе обложкой станет произвольный чужой id). Ошибка —
|
||||
`BadRequest`/`NotFound`. `clear_cover` по-прежнему очищает.
|
||||
|
||||
### 5. Иконка — inline-SVG, диалог — нативный `<dialog>`
|
||||
|
||||
Минимальный компонент иконки (один SVG на кнопку создания) с `title` +
|
||||
`aria-label` для хинта. Диалог имени — нативный `<dialog>`: ESC, backdrop и
|
||||
focus-trap бесплатно, не тащим оверлей как в `Lightbox`. Поле — только
|
||||
«Название»; описание остаётся в настройках.
|
||||
|
||||
### 6. Очистка висячей обложки при удалении фото
|
||||
|
||||
Так как FK нет, при удалении фото, которое является обложкой, бэкенд SHALL
|
||||
очищать `cover_photo_id` (в `photos.Service.Delete` или через `SetCover`-подобный
|
||||
метод). Иначе получим обложку на несуществующее фото (визуально — заглушка).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Presigned `cover_url` живёт ~1 час, а список может висеть открытым дольше] →
|
||||
Приемлемо: список перезагружается при навигации; при желании позже добавим
|
||||
refetch. Для MVP не блокирует.
|
||||
- [Подзапрос за `thumb_key` на каждый альбом в списке] → Дёшево при индексе
|
||||
`photos` по PK; presign — локальная подпись без сетевого вызова.
|
||||
- [`cover_photo_id` задан, но варианты ещё не собраны] → `thumb_key` пуст →
|
||||
`cover_url = null` → карточка показывает заглушку; авто-обложка ставится только
|
||||
после сборки вариантов, ручной выбор сразу после загрузки может временно не
|
||||
иметь миниатюры.
|
||||
- [Пользователь уберёт обложку во время загрузки нового фото] → авто-обложка
|
||||
снова назначит свежее фото, если обложка пуста — ожидаемо по спеке.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Миграции не нужны. Изменения аддитивны: новое поле `cover_url` в JSON; старые
|
||||
клиенты его игнорируют. Порядок выката: бэкенд, затем фронтенд. Откат —
|
||||
возврат предыдущего бинарника и статики; данные не меняются.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Список альбомов пустой и безликий: карточки показывают заглушку `🖼️`, а создание
|
||||
альбома — постоянная inline-форма в заголовке. При этом бэкенд уже хранит
|
||||
`cover_photo_id` и умеет его менять, но фронтенд нигде не отдаёт URL обложки и не
|
||||
даёт её выбрать. Нужно довести UI до состояния, где альбомы узнаваемы по
|
||||
обложкам, а создание не занимает место в шапке.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Кнопка «Создать» в списке альбомов превращается в компактную иконку с хинтом
|
||||
(`title` + `aria-label`). По клику открывается диалог с полем «Название»;
|
||||
отправка создаёт альбом через существующий `POST /albums`.
|
||||
- `GET /albums` начинает возвращать `cover_url` — presigned URL миниатюры
|
||||
обложки (или `null`), чтобы карточка списка могла показать изображение.
|
||||
- Карточки альбомов в списке показывают обложку; при её отсутствии — прежнюю
|
||||
заглушку.
|
||||
- В настройках альбома появляется секция «Обложка»: сетка миниатюр фото
|
||||
альбома, клик по фото применяет его обложкой (`PATCH` с `cover_photo_id`),
|
||||
кнопка «Убрать» очищает (`clear_cover`).
|
||||
- Автоматическая обложка при загрузке фото ставится только если обложка ещё
|
||||
пустая, чтобы не затирать ручной выбор владельца.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `albums`: создание альбома через иконку-кнопку и диалог; `cover_url` в списке
|
||||
альбомов; выбор и очистка обложки в настройках; правило автоматической
|
||||
обложки (только при пустой).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Frontend:** `src/routes/+page.svelte` (иконка, диалог, рендер обложки),
|
||||
`src/routes/albums/[id]/settings/+page.svelte` (секция «Обложка»),
|
||||
`src/lib/types.ts` (`Album.cover_url`), возможно новый переиспользуемый
|
||||
inline-SVG компонент иконки.
|
||||
- **Backend:** `internal/albums/store.go` и `internal/albums/service.go`
|
||||
(получение thumb-ключа обложки и presign `cover_url`), `internal/photos/store.go`
|
||||
(`SetCover` только при пустой обложке).
|
||||
- **API:** ответ `GET /albums` расширяется полем `cover_url` (аддитивно,
|
||||
обратносовместимо). `PATCH /albums/:id` уже поддерживает `cover_photo_id` и
|
||||
`clear_cover` — изменений контракта не требуется.
|
||||
- **Данные/миграции:** не требуются.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Создание альбома
|
||||
Зарегистрированный пользователь SHALL иметь возможность создать альбом и стать его владельцем.
|
||||
|
||||
#### Scenario: Создание альбома
|
||||
- **WHEN** пользователь создаёт альбом с названием
|
||||
- **THEN** система создаёт альбом и назначает пользователя владельцем
|
||||
|
||||
#### Scenario: Создание через диалог из списка
|
||||
- **WHEN** пользователь в списке альбомов активирует кнопку создания и вводит название в открывшемся диалоге
|
||||
- **THEN** система создаёт альбом с указанным названием и показывает его в списке
|
||||
|
||||
### Requirement: Редактирование альбома
|
||||
Владелец и админ SHALL иметь возможность редактировать название, описание и обложку альбома.
|
||||
|
||||
#### Scenario: Редактирование альбома
|
||||
- **WHEN** админ изменяет описание альбома
|
||||
- **THEN** система сохраняет изменения
|
||||
|
||||
#### Scenario: Выбор обложки из фото альбома
|
||||
- **WHEN** владелец или админ выбирает одно из фото альбома в качестве обложки
|
||||
- **THEN** система сохраняет выбранное фото как обложку альбома
|
||||
|
||||
#### Scenario: Очистка обложки
|
||||
- **WHEN** владелец или админ убирает обложку альбома
|
||||
- **THEN** система очищает обложку альбома
|
||||
|
||||
#### Scenario: Обложка недоступна без прав
|
||||
- **WHEN** участник без прав редактирования альбома пытается изменить обложку
|
||||
- **THEN** система отклоняет запрос
|
||||
|
||||
#### Scenario: Обложкой можно назначить только фото этого альбома
|
||||
- **WHEN** в качестве обложки передан id фото, не принадлежащего альбому
|
||||
- **THEN** система отклоняет запрос и не меняет обложку
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Обложка альбома в списке
|
||||
Система SHALL включать в список альбомов URL миниатюры обложки для каждого альбома, у которого обложка задана.
|
||||
|
||||
#### Scenario: Список содержит URL обложки
|
||||
- **WHEN** пользователь запрашивает список альбомов и у альбома задана обложка
|
||||
- **THEN** система возвращает для этого альбома URL миниатюры обложки
|
||||
|
||||
#### Scenario: Обложка не задана
|
||||
- **WHEN** у альбома обложка не задана
|
||||
- **THEN** система возвращает пустое значение обложки для этого альбома
|
||||
|
||||
### Requirement: Автоматическая обложка
|
||||
Система SHALL назначать первое обработанное фото альбома обложкой только если обложка ещё не задана.
|
||||
|
||||
#### Scenario: Назначение при пустой обложке
|
||||
- **WHEN** фото альбома обработано, и у альбома обложка не задана
|
||||
- **THEN** система назначает это фото обложкой альбома
|
||||
|
||||
#### Scenario: Ручной выбор не затирается
|
||||
- **WHEN** фото альбома обработано, а обложка уже задана
|
||||
- **THEN** система не изменяет текущую обложку
|
||||
|
||||
### Requirement: Удаление фото-обложки
|
||||
Система SHALL очищать обложку альбома, если фото, назначенное обложкой, удалено.
|
||||
|
||||
#### Scenario: Удалено фото-обложка
|
||||
- **WHEN** удаляется фото, назначенное обложкой альбома
|
||||
- **THEN** система очищает обложку альбома и список не возвращает URL для неё
|
||||
@@ -0,0 +1,32 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Обложки — бэкенд
|
||||
|
||||
- [x] 1.1 Добавить в `internal/models/models.go` поле `CoverURL string` с тегом `json:"cover_url,omitempty"` у `Album`; убедиться, что `cd backend && go build ./...` проходит
|
||||
- [x] 1.2 Расширить выборки `internal/albums/store.go` (`ListForUser`, `GetByID`) подзапросом к `photos` за `thumb_key` по `cover_photo_id`; проверить сборку и что при отсутствующем фото ключ пустой
|
||||
- [x] 1.3 В `internal/albums/service.go` presign получить thumb обложки через существующий `storage.Client` и проставить `CoverURL` в `List` и `Get`; проверить, что `GET /albums` возвращает `cover_url` для альбома с обложкой
|
||||
- [x] 1.4 Сделать `photos.Store.SetCover` условным (`... WHERE id = $1 AND cover_photo_id IS NULL`), чтобы не затирать ручной выбор; проверить сборку и покрыть тестом из 1.6
|
||||
- [x] 1.5 В `internal/albums/service.go` метод `Update`: при заданном `CoverPhotoID` отклонять id фото не из этого альбома (`BadRequest`), обложку не менять; проверить сборку
|
||||
- [x] 1.6 В `internal/photos/service.go` метод `Delete`: если удаляемое фото совпадает с обложкой альбома, очистить `cover_photo_id`; проверить сборку
|
||||
- [x] 1.7 Добавить/обновить тесты на авто-обложку (не затирает), отклонение чужого фото-обложки и очистку при удалении; `make test` проходит (интеграционные тесты репозиториев — с `TEST_DATABASE_URL`)
|
||||
|
||||
## 2. Обложки — список альбомов
|
||||
|
||||
- [x] 2.1 Добавить `cover_url?: string | null` в интерфейс `Album` в `frontend/src/lib/types.ts`; `cd frontend && npm run check` без ошибок
|
||||
- [x] 2.2 В `frontend/src/routes/+page.svelte` в карточке показывать изображение по `cover_url` (aspect-video, object-cover), при отсутствии — прежнюю заглушку; проверить `npm run build` и визуально, что карточка с обложкой показывает фото
|
||||
|
||||
## 3. Обложки — выбор в настройках
|
||||
|
||||
- [x] 3.1 В `frontend/src/routes/albums/[id]/settings/+page.svelte` добавить секцию «Обложка»: загрузить фото альбома (`GET /albums/:id/photos`) и показать сетку миниатюр; проверить `npm run check`
|
||||
- [x] 3.2 Клик по миниатюре применяет обложку (`PATCH /albums/:id` с `cover_photo_id`), кнопка «Убрать» вызывает `clear_cover`; обновлять локальное состояние без перезагрузки; проверить визуально, что выбранная обложка помечена и появляется в списке
|
||||
- [x] 3.3 Секция видна только владельцу/админу (`role`); проверить, что у участника без прав её нет
|
||||
|
||||
## 4. Создание альбома — иконка и диалог
|
||||
|
||||
- [x] 4.1 Добавить минимальный inline-SVG компонент иконки (например `frontend/src/lib/components/Icon.svelte`) с поддержкой `class`, `aria-label`/`title`; проверить `npm run check`
|
||||
- [x] 4.2 В `frontend/src/routes/+page.svelte` заменить inline-форму на кнопку-иконку с хинтом (`title` + `aria-label`), открывающую нативный `<dialog>` с полем «Название»; отправка создаёт альбом через существующий `POST /albums` и закрывает диалог; проверить `npm run build` и визуально: создание через диалог добавляет альбом в список
|
||||
|
||||
## 5. Интеграция
|
||||
|
||||
- [x] 5.1 Выполнить `make build` (фронтенд -> `backend/static` -> бинарник) и убедиться, что сборка проходит без ошибок
|
||||
- [x] 5.2 Запустить сервер (`make run`) и вручную проверить сквозной сценарий: создание через диалог, загрузка первого фото назначает обложку, ручной выбор не затирается следующей загрузкой, удаление фото-обложки очищает обложку в списке
|
||||
Reference in New Issue
Block a user