Files
ds-galo/frontend/src/lib/components/Uploader.svelte
T
iu 304cfedb75 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)
2026-10-10 18:15:13 +03:00

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}