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)
This commit is contained in:
iu
2026-10-10 18:15:13 +03:00
parent b90e6b0267
commit 304cfedb75
47 changed files with 1942 additions and 601 deletions
+40 -64
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types';
interface Props {
@@ -10,39 +9,23 @@
let { albumId, onUploaded }: Props = $props();
let dragging = $state(false);
let progress = $state(0);
let fileInput: HTMLInputElement | undefined = $state();
let busy = $state(false);
let progress = $state(0);
let error = $state('');
function pick() {
export 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) {
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 Array.from(files)) {
form.append('files', file);
}
for (const file of list) form.append('files', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/albums/${albumId}/photos`);
@@ -53,6 +36,7 @@
xhr.onload = () => {
busy = false;
if (xhr.status >= 200 && xhr.status < 300) {
progress = 0;
try {
const data = JSON.parse(xhr.responseText);
onUploaded?.(data.photos ?? []);
@@ -73,48 +57,40 @@
};
xhr.send(form);
}
function onChange(ev: Event) {
const input = ev.target as HTMLInputElement;
if (input.files?.length) upload(input.files);
input.value = '';
}
</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>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={onChange}
/>
{#if busy}
<div class="bg-bg-muted mt-4 h-2 w-full overflow-hidden rounded">
{#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>
{/if}
{#if error}
<p class="text-danger mt-3 text-sm">{error}</p>
{/if}
</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}