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)
97 lines
2.3 KiB
Svelte
97 lines
2.3 KiB
Svelte
<script lang="ts">
|
|
import { auth } from '$lib/stores/auth.svelte';
|
|
import type { Photo } from '$lib/types';
|
|
|
|
interface Props {
|
|
albumId: string;
|
|
onUploaded?: (photos: Photo[]) => void;
|
|
}
|
|
|
|
let { albumId, onUploaded }: Props = $props();
|
|
|
|
let fileInput: HTMLInputElement | undefined = $state();
|
|
let busy = $state(false);
|
|
let progress = $state(0);
|
|
let error = $state('');
|
|
|
|
export function pick() {
|
|
fileInput?.click();
|
|
}
|
|
|
|
export function upload(files: FileList | File[]) {
|
|
const list = Array.from(files);
|
|
if (list.length === 0) return;
|
|
error = '';
|
|
busy = true;
|
|
progress = 0;
|
|
const form = new FormData();
|
|
for (const file of list) form.append('files', file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', `/api/albums/${albumId}/photos`);
|
|
if (auth.access) xhr.setRequestHeader('Authorization', `Bearer ${auth.access}`);
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) progress = Math.round((e.loaded / e.total) * 100);
|
|
};
|
|
xhr.onload = () => {
|
|
busy = false;
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
progress = 0;
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
onUploaded?.(data.photos ?? []);
|
|
} catch {
|
|
onUploaded?.([]);
|
|
}
|
|
} else {
|
|
try {
|
|
error = JSON.parse(xhr.responseText)?.error?.message ?? 'Ошибка загрузки';
|
|
} catch {
|
|
error = 'Ошибка загрузки';
|
|
}
|
|
}
|
|
};
|
|
xhr.onerror = () => {
|
|
busy = false;
|
|
error = 'Сетевая ошибка';
|
|
};
|
|
xhr.send(form);
|
|
}
|
|
|
|
function onChange(ev: Event) {
|
|
const input = ev.target as HTMLInputElement;
|
|
if (input.files?.length) upload(input.files);
|
|
input.value = '';
|
|
}
|
|
</script>
|
|
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
class="hidden"
|
|
onchange={onChange}
|
|
/>
|
|
|
|
{#if busy}
|
|
<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>
|
|
</div>
|
|
{/if}
|
|
{#if error}
|
|
<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}
|