В скрытом режиме панели больше не растягивают плитки: сетка пересчитывает число колонок так, чтобы размер миниатюры остался прежним, а в строке помещалось больше фото. Каркас публикует ширину, которую занимают панели в текущей раскладке (рейл на широких экранах, панель на сверхшироких), сетка использует её для пересчёта, а навигация курсора стрелками учитывает реальное число колонок.
613 lines
18 KiB
Svelte
613 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));
|
|
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}
|