commit
This commit is contained in:
@@ -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
|
||||
>
|
||||
</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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user