This commit is contained in:
iu
2026-10-10 14:21:43 +03:00
parent 84ce4d1930
commit 9329ebc023
17 changed files with 591 additions and 34 deletions
+29
View File
@@ -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>
+1
View File
@@ -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;
}
+59 -11
View File
@@ -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
>
</form>
<button
type="button"
onclick={openDialog}
title="Создать альбом"
aria-label="Создать альбом"
class="bg-primary text-primary-fg rounded-lg p-2"
>
<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}
+1 -1
View File
@@ -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}>