Files
ds-galo/frontend/src/routes/(app)/albums/[id]/+page.svelte
T
iu 0548b1ab5e feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
2026-10-20 23:06:24 +03:00

618 lines
18 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { tick, untrack } from 'svelte';
import { api, photosApi } from '$lib/api';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Uploader from '$lib/components/Uploader.svelte';
import DensityControl from '$lib/components/DensityControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { albumAccessLost } from '$lib/albums-events';
import { grid } from '$lib/stores/grid.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
import { trash } from '$lib/stores/trash.svelte';
import { viewerReturn } from '$lib/stores/viewer-return';
import { transfer } from '$lib/stores/transfer.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { panels } from '$lib/stores/panels.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
let album = $state<Album | null>(null);
let role = $state('');
let photos = $state<Photo[]>([]);
let nextCursor = $state('');
let loading = $state(true);
let error = $state('');
let dragging = $state(false);
let pendingDelete = $state(false);
let deletingMenu = $state(false);
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
let cursorIndex = $state<number | null>(null);
let gridFocused = $state(false);
let gridSurface: HTMLDivElement | undefined = $state();
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
let canEdit = $derived(role === 'owner' || role === 'admin');
async function loadAlbum() {
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
album = data.album;
role = data.role;
albums.upsert(data.album);
}
async function loadPhotos() {
const data = await api<{ photos: Photo[]; next_cursor: string }>(
`/albums/${albumId}/photos`
);
photos = data.photos;
nextCursor = data.next_cursor;
}
async function loadMore() {
if (!nextCursor) return;
const data = await api<{ photos: Photo[]; next_cursor: string }>(
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
);
photos = [...photos, ...data.photos];
nextCursor = data.next_cursor;
}
$effect(() => {
const id = albumId;
const returnPhotoId = viewerReturn.consume(id);
selection.clear();
cursorIndex = null;
loading = true;
error = '';
Promise.all([loadAlbum(), loadPhotos()])
.then(async () => {
loading = false;
if (returnPhotoId) await restoreFocus(id, returnPhotoId);
})
.catch((e) => {
error = (e as Error).message;
loading = false;
});
});
$effect(() => {
// The personal stream removes albums from the store on deletion or access
// loss (member excluded); when the album open here disappears, leave to
// the overview instead of showing a stale page.
if (!albums.loaded || loading || !album) return;
if (!albumAccessLost(albums.list, albumId)) return;
goto('/');
});
$effect(() => {
// Patch a photo edited in the panel (e.g. tags) into the grid list so
// derived UI like the 360 badge updates without a reload. Read the list
// untracked so writing it cannot re-trigger this effect.
const updated = photoUpdates.latest;
if (!updated) return;
const list = untrack(() => photos);
if (list.some((p) => p.id === updated.id)) {
photos = list.map((p) => (p.id === updated.id ? updated : p));
}
});
let client: SSEClient | null = null;
$effect(() => {
const id = albumId;
if (!auth.access) return;
client = new SSEClient({
path: `/api/sse/albums/${id}`,
onEvent: handleEvent,
onReconnect: () => loadPhotos()
});
client.connect(auth.access);
return () => {
client?.close();
client = null;
};
});
function handleEvent(ev: SSEEvent) {
const data = ev.data as Record<string, unknown>;
switch (ev.type) {
case 'photo.created': {
const photo = data as unknown as Photo;
if (photos.some((p) => p.id === photo.id)) {
photos = photos.map((p) => (p.id === photo.id ? photo : p));
} else {
photos = [photo, ...photos];
}
loadAlbum().catch(() => {});
break;
}
case 'photo.deleted': {
photos = photos.filter((p) => p.id !== data.id);
loadAlbum().catch(() => {});
break;
}
case 'photo.updated': {
// Variant readiness updates a photo already in the grid; never
// insert an unseen photo here, or the grid would reflow.
const photo = data as unknown as Photo;
photos = photos.map((p) => (p.id === photo.id ? photo : p));
// Обновлённое фото (например, отрендеренные правки) должно
// попасть и в правую панель.
if (selection.albumId === albumId && selection.has(photo.id)) {
selection.replace(photo);
}
break;
}
case 'like.added': {
photos = photos.map((p) =>
p.id === data.photo_id
? {
...p,
likes_count: p.likes_count + 1,
liked_by_me: data.user_id === auth.user?.id || p.liked_by_me
}
: p
);
break;
}
case 'like.removed': {
photos = photos.map((p) =>
p.id === data.photo_id
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
: p
);
break;
}
case 'comment.created': {
const comment = data as unknown as { photo_id: string };
photos = photos.map((p) =>
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
);
break;
}
case 'album.updated': {
loadAlbum().catch(() => {});
break;
}
}
}
function selectPhoto(i: number, e: MouseEvent) {
const photo = photos[i];
if (!photo) return;
cursorIndex = i;
const cur = selection.albumId === albumId ? selection.items : [];
if (e.shiftKey && selection.anchorId && selection.albumId === albumId) {
const a = photos.findIndex((p) => p.id === selection.anchorId);
if (a >= 0) {
const lo = Math.min(a, i);
const hi = Math.max(a, i);
selection.set(albumId, photos.slice(lo, hi + 1));
return;
}
}
if (e.ctrlKey || e.metaKey) {
const exists = cur.some((p) => p.id === photo.id);
selection.set(
albumId,
exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]
);
selection.anchorId = photo.id;
return;
}
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
function dragPhoto(i: number, e: DragEvent) {
const photo = photos[i];
if (!photo) return;
cursorIndex = i;
if (!(selection.albumId === albumId && selection.has(photo.id))) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
const ids = selection.ids;
e.dataTransfer?.setData('application/x-galo-photos', JSON.stringify(ids));
e.dataTransfer?.setData('text/plain', `${ids.length} фото`);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copyMove';
}
function onDrop(ev: DragEvent) {
ev.preventDefault();
dragging = false;
if (canUpload && ev.dataTransfer?.files?.length) {
uploader?.upload(ev.dataTransfer.files);
}
}
function openMenuForPhoto(i: number, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const photo = photos[i];
if (!photo) return;
cursorIndex = i;
const inSel = selection.albumId === albumId && selection.has(photo.id);
const multi = inSel && selection.count > 1;
if (!inSel) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
const openFirst = () => {
const first = selection.albumId === albumId ? selection.items[0] : photo;
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
};
let items: ContextMenuItem[];
if (multi) {
const ids = selection.ids;
items = [
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
{ label: 'Переместить', icon: 'arrow_back', run: () => transfer.start(ids, null, 'move') },
{ label: 'Копировать', icon: 'content_copy', run: () => transfer.start(ids, null, 'copy') },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => (pendingDelete = true) }
];
} else {
items = [
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
...(photo.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(photo.download_url, '_blank')
}
]
: []),
...(canEdit
? [
{
label: 'Установить как обложку альбома',
icon: 'wallpaper' as const,
run: () => albums.setCover(albumId, photo.id)
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([photo.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([photo.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
pendingDelete = true;
}
}
];
}
contextMenu.show(e.clientX, e.clientY, items);
}
async function confirmMenuDelete() {
deletingMenu = true;
try {
await photosApi.deleteBatch(selection.ids);
selection.clear();
pendingDelete = false;
await trash.load();
} catch (e) {
error = (e as Error).message;
} finally {
deletingMenu = false;
}
}
function cellAt(i: number): HTMLElement | null {
return gridSurface?.querySelectorAll<HTMLElement>('[data-photo-cell]')[i] ?? null;
}
function focusCursor(i: number) {
if (i < 0 || i >= photos.length) return;
cursorIndex = i;
const el = cellAt(i);
if (el) {
el.focus({ preventScroll: true });
el.scrollIntoView({ block: 'nearest' });
}
}
function extendSelection(next: number, origin: number) {
let anchorIdx = origin;
if (selection.albumId === albumId && selection.anchorId) {
const found = photos.findIndex((p) => p.id === selection.anchorId);
if (found >= 0) anchorIdx = found;
}
if (anchorIdx < 0 || anchorIdx >= photos.length) anchorIdx = next;
const anchorPhoto = photos[anchorIdx];
if (anchorPhoto) selection.anchorId = anchorPhoto.id;
const lo = Math.min(anchorIdx, next);
const hi = Math.max(anchorIdx, next);
selection.set(albumId, photos.slice(lo, hi + 1));
focusCursor(next);
}
async function moveCursor(next: number, origin: number, shift: boolean) {
if (next < 0) return;
if (next >= photos.length) {
if (!nextCursor) return;
await loadMore();
await tick();
if (next >= photos.length) return;
}
const i = Math.min(photos.length - 1, next);
if (shift) extendSelection(i, origin);
else focusCursor(i);
}
async function restoreFocus(id: string, targetId: string) {
let idx = photos.findIndex((p) => p.id === targetId);
while (idx < 0 && nextCursor) {
await loadMore();
idx = photos.findIndex((p) => p.id === targetId);
}
if (idx < 0) return;
const photo = photos[idx];
selection.set(id, [photo]);
selection.anchorId = photo.id;
cursorIndex = idx;
await tick();
const el = cellAt(idx);
if (el) {
el.focus({ preventScroll: true });
el.scrollIntoView({ block: 'center' });
}
}
function toggleCursor() {
const i = cursorIndex;
if (i == null) return;
const photo = photos[i];
if (!photo) return;
const cur = selection.albumId === albumId ? selection.items : [];
const exists = cur.some((p) => p.id === photo.id);
selection.set(albumId, exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]);
selection.anchorId = photo.id;
}
function onGridKeydown(e: KeyboardEvent) {
const target = e.target as Element | null;
if (!target) return;
const inCell = !!target.closest('[data-photo-cell]');
if (!inCell && target.closest('button, a, input, select, textarea')) return;
if (photos.length === 0) return;
const key = e.key;
if (
key === 'ArrowLeft' ||
key === 'ArrowRight' ||
key === 'ArrowUp' ||
key === 'ArrowDown'
) {
e.preventDefault();
if (cursorIndex == null) {
if (e.shiftKey) extendSelection(0, 0);
else focusCursor(0);
return;
}
const origin = cursorIndex;
const cols = Math.max(1, grid.cols);
let next = origin;
if (key === 'ArrowLeft') next = origin - 1;
else if (key === 'ArrowRight') next = origin + 1;
else if (key === 'ArrowUp') next = origin - cols;
else next = origin + cols;
moveCursor(next, origin, e.shiftKey);
return;
}
if (key === ' ' || key === 'Spacebar') {
if (cursorIndex == null) return;
e.preventDefault();
toggleCursor();
return;
}
if (key === 'Enter') {
if (cursorIndex == null) return;
const p = photos[cursorIndex];
if (!p) return;
e.preventDefault();
goto(`/albums/${albumId}/photos/${p.id}`);
return;
}
if (key === 'Escape') {
if (selection.albumId === albumId && selection.items.length > 0) {
e.preventDefault();
selection.clear();
}
}
}
function onGridClick(e: MouseEvent) {
const target = e.target as Element | null;
if (!target) return;
if (target.closest('[data-photo-cell]')) return;
if (target.closest('button, a, input, select, textarea, [role="button"]')) return;
if (selection.albumId === albumId && selection.items.length > 0) {
selection.clear();
}
gridSurface?.focus();
}
function onGridFocusIn() {
gridFocused = true;
}
function onGridFocusOut(e: FocusEvent) {
const el = e.currentTarget as Node | null;
const next = e.relatedTarget as Node | null;
if (!el || !next || !el.contains(next)) gridFocused = false;
}
</script>
{#if loading}
<p class="text-text-muted p-4">Загрузка…</p>
{:else if error}
<p class="text-danger p-4">{error}</p>
{:else}
<div
class="relative flex min-h-full flex-col"
role="region"
aria-label="Альбом"
ondragover={(e) => {
if (!canUpload) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
dragging = true;
}}
ondragleave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) dragging = false;
}}
ondrop={onDrop}
>
<div
class="border-border bg-chrome sticky top-0 z-10 flex items-center justify-between gap-3 border-b px-3 py-2"
>
<div class="flex min-w-0 items-center gap-1">
<a
href="/"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex shrink-0 items-center justify-center rounded-md p-1.5"
aria-label="Ко всем альбомам"
title="Ко всем альбомам"
>
<Icon name="arrow_back" class="size-5" />
</a>
<h1 class="text-text truncate text-sm font-medium">{album?.title ?? ''}</h1>
</div>
<div class="flex items-center gap-3">
<DensityControl />
<span class="bg-border hidden h-5 w-px shrink-0 lg:block" aria-hidden="true"></span>
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text hidden items-center justify-center rounded-md p-1.5 lg:inline-flex"
aria-label={panels.hidden ? 'Показать боковые панели (Tab)' : 'Скрыть боковые панели (Tab)'}
title={panels.hidden ? 'Показать боковые панели (Tab)' : 'Скрыть боковые панели (Tab)'}
aria-keyshortcuts="Tab"
aria-pressed={panels.hidden}
onclick={() => panels.toggle()}
>
<Icon name={panels.hidden ? 'left_panel_open' : 'left_panel_close'} />
</button>
{#if canUpload}
<span class="bg-border h-5 w-px shrink-0" aria-hidden="true"></span>
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5"
aria-label="Загрузить фото"
title="Загрузить фото"
onclick={() => uploader?.pick()}
>
<Icon name="cloud_upload" />
</button>
{/if}
</div>
</div>
<div
class="flex-1 p-3 sm:p-4"
role="grid"
aria-label="Сетка фотографий"
aria-multiselectable="true"
tabindex="-1"
bind:this={gridSurface}
onclick={onGridClick}
onkeydown={onGridKeydown}
onfocusin={onGridFocusIn}
onfocusout={onGridFocusOut}
>
{#if canUpload && photos.length === 0}
<button
type="button"
class="border-border text-text-muted hover:border-primary flex w-full flex-col items-center gap-3 rounded-xl border-2 border-dashed p-12 text-center"
onclick={() => uploader?.pick()}
>
<Icon name="cloud_upload" class="size-10 text-text-subtle" />
<span>Перетащите фото сюда или выберите файлы</span>
</button>
{:else}
<PhotoGrid
{photos}
selectedIds={selection.albumId === albumId ? selection.ids : []}
cursorIndex={cursorIndex}
cursorVisible={gridFocused}
onCursorFocus={(i) => (cursorIndex = i)}
onSelect={selectPhoto}
onDragStart={dragPhoto}
onContextMenu={openMenuForPhoto}
onOpen={(i) => {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
}}
/>
{/if}
{#if nextCursor}
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
</div>
{#if dragging}
<div
class="bg-bg/80 border-primary pointer-events-none fixed inset-3 z-40 flex items-center justify-center rounded-xl border-2 border-dashed"
>
<span class="text-text inline-flex items-center gap-2 text-lg">
<Icon name="cloud_upload" class="size-6" />
Отпустите, чтобы загрузить
</span>
</div>
{/if}
<Uploader
bind:this={uploader}
{albumId}
onUploaded={(created) => {
for (const p of created) {
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
}
loadAlbum().catch(() => {});
}}
/>
</div>
{/if}
{#if pendingDelete}
<ConfirmDialog
title="Удалить выбранные фото?"
message="{selection.count} фото будут перемещены в корзину."
confirmLabel="Удалить"
busy={deletingMenu}
onConfirm={confirmMenuDelete}
onClose={() => (pendingDelete = false)}
/>
{/if}