Files
ds-galo/frontend/src/lib/components/Uploader.svelte
T
iu 997ec5e6c1 feat(frontend): replace emoji with inline Material Symbols icon system
Add a self-contained icon system: vendored Material Symbols registry, an
<Icon> component that references a single <symbol> sprite via <use>, plus
IconButton/IconSprite. Replace every emoji/Unicode glyph (logo, theme
switcher, cover placeholder, like/comment counts, lightbox controls, back
links) and add leading icons to key controls. Decorative icons are
aria-hidden; icon-only buttons get labels; theme toggles expose
aria-pressed; counters get accessible names.

Recorded in openspec change material-icons-ui (all tasks complete).
2026-10-10 13:19:29 +03:00

121 lines
3.0 KiB
Svelte

<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types';
interface Props {
albumId: string;
onUploaded?: (photos: Photo[]) => void;
}
let { albumId, onUploaded }: Props = $props();
let dragging = $state(false);
let progress = $state(0);
let busy = $state(false);
let error = $state('');
function pick() {
fileInput?.click();
}
let fileInput: HTMLInputElement | undefined = $state();
function onDrop(ev: DragEvent) {
ev.preventDefault();
dragging = false;
if (ev.dataTransfer?.files?.length) {
upload(ev.dataTransfer.files);
}
}
function onChange(ev: Event) {
const target = ev.target as HTMLInputElement;
if (target.files?.length) upload(target.files);
target.value = '';
}
function upload(files: FileList) {
error = '';
busy = true;
progress = 0;
const form = new FormData();
for (const file of Array.from(files)) {
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) {
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);
}
</script>
<div
class="border-border rounded-xl border-2 border-dashed p-6 text-center transition-colors {dragging
? 'bg-bg-muted border-primary'
: 'bg-bg-elevated'}"
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
ondragenter={(e) => e.preventDefault()}
role="region"
aria-label="Загрузка фотографий"
>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={onChange}
/>
<Icon name="cloud_upload" class="text-text-subtle mx-auto mb-3 size-10" />
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
<button
type="button"
onclick={pick}
disabled={busy}
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
<Icon name="add_photo_alternate" class="size-4" />
{busy ? 'Загрузка…' : 'Выбрать файлы'}
</button>
{#if busy}
<div class="bg-bg-muted mt-4 h-2 w-full overflow-hidden rounded">
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
</div>
{/if}
{#if error}
<p class="text-danger mt-3 text-sm">{error}</p>
{/if}
</div>