feat: context menus, unified photo panel, tags, album trash and counters

- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count
- frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs
- openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
This commit is contained in:
iu
2026-10-10 22:45:41 +03:00
parent 2d6a3fea1a
commit 61b45b2742
37 changed files with 1639 additions and 328 deletions
+16 -3
View File
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte';
import type { Photo, Preferences } from './types';
import type { Photo, Preferences, Tag } from './types';
export class ApiError extends Error {
status: number;
@@ -120,6 +120,19 @@ export const photosApi = {
body: { ids, album_id: albumId }
}),
trash: () => api<{ photos: Photo[] }>('/trash'),
restore: (id: string) => api<{ photo: Photo }>(`/photos/${id}/restore`, { method: 'POST' }),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' })
trashCount: () => api<{ count: number }>('/trash/count'),
restore: (id: string, albumId?: string) =>
api<{ photo: Photo }>(`/photos/${id}/restore`, {
method: 'POST',
body: albumId ? { album_id: albumId } : {}
}),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
addTag: (id: string, name: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
removeTag: (id: string, tagId: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags/${tagId}`, { method: 'DELETE' })
};
export const tagsApi = {
list: (q = '') => api<{ tags: Tag[] }>(`/tags${q ? `?q=${encodeURIComponent(q)}` : ''}`)
};
@@ -0,0 +1,119 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
let title = $state('');
let busy = $state(false);
let error = $state('');
$effect(() => {
const a = albumActions.renaming;
if (a) {
title = a.title;
error = '';
}
});
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
async function saveRename(ev: Event) {
ev.preventDefault();
const a = albumActions.renaming;
if (!a || !title.trim() || busy) return;
busy = true;
error = '';
try {
await albums.rename(a.id, title.trim());
albumActions.renaming = null;
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function confirmDelete() {
const a = albumActions.deleting;
if (!a) return;
busy = true;
error = '';
try {
await albums.destroy(a.id);
albumActions.deleting = null;
if ($page.params.id === a.id) await goto('/');
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if albumActions.renaming}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Переименовать альбом"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !busy) albumActions.renaming = null;
}}
>
<form
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
onsubmit={saveRename}
>
<h2 class="text-text text-lg font-semibold">Переименовать альбом</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Название</span>
<input
bind:value={title}
use:autofocus
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => (albumActions.renaming = null)}
>
Отмена
</button>
<button
type="submit"
disabled={busy || !title.trim()}
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
<Icon name="check" class="size-4" />
{busy ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</form>
</div>
{/if}
{#if albumActions.deleting}
<ConfirmDialog
title="Удалить альбом?"
message="Альбом «{albumActions.deleting.title}» будет удалён."
confirmLabel="Удалить"
busy={busy}
onConfirm={confirmDelete}
onClose={() => (albumActions.deleting = null)}
/>
{/if}
+17 -227
View File
@@ -4,16 +4,13 @@
import { api, photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import CoverPicker from './CoverPicker.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import PhotoPanel from './PhotoPanel.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
import type { Album, Photo, Preferences } from '$lib/types';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
@@ -24,40 +21,13 @@
let picking = $state(false);
let photo = $state<Photo | null>(null);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(
!!panelPhoto && (canEdit || panelPhoto.uploader_id === auth.user?.id)
);
const exif = $derived((panelPhoto?.exif ?? {}) as Record<string, unknown>);
const prefs = $derived(auth.preferences);
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
const visiblePrefs = $derived(prefs.photo_info?.visible);
const effectiveVisible = $derived(
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
);
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
const attributeKeys = $derived(
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
)
);
let settingsOpen = $state(false);
let showAll = $state(false);
let savingPrefs = $state(false);
let pendingDeleteSelected = $state(false);
let deletingSelected = $state(false);
@@ -79,21 +49,13 @@
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
}
async function savePreferences(next: Preferences) {
savingPrefs = true;
try {
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
body: next
});
auth.setPreferences(res.preferences ?? {});
showAll = false;
settingsOpen = false;
} catch (e) {
error = (e as Error).message;
} finally {
savingPrefs = false;
}
function onRenamed(updated: Photo) {
photo = updated;
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
}
function onDeleted() {
selection.clear();
}
$effect(() => {
@@ -115,9 +77,6 @@
$effect(() => {
const id = photoId;
editingName = false;
nameError = '';
showAll = false;
if (!id) {
photo = null;
return;
@@ -127,58 +86,6 @@
.catch((e) => (error = (e as Error).message));
});
$effect(() => {
// сброс временного состояния панели при выборе другого фото в сетке
selection.ids.join(',');
editingName = false;
nameError = '';
showAll = false;
});
function startEdit() {
if (!panelPhoto) return;
nameDraft = panelPhoto.original_filename;
nameError = '';
editingName = true;
}
async function saveName(ev: Event) {
ev.preventDefault();
if (!panelPhoto || !nameDraft.trim()) return;
savingName = true;
nameError = '';
try {
const data = await api<{ photo: Photo }>(`/photos/${panelPhoto.id}`, {
method: 'PATCH',
body: { original_filename: nameDraft.trim() }
});
photo = data.photo;
if (selectedItems.some((p) => p.id === data.photo.id)) selection.replace(data.photo);
editingName = false;
} catch (e) {
nameError = (e as Error).message;
} finally {
savingName = false;
}
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
async function resetCover() {
if (!album) return;
try {
@@ -190,7 +97,6 @@
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
</script>
{#snippet selectionActions()}
@@ -230,130 +136,14 @@
{/snippet}
{#if panelPhoto}
{@const p = panelPhoto}
{#if !photoId}{@render selectionActions()}{/if}
<div>
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
bind:value={nameDraft}
use:autofocus
aria-label="Имя файла"
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
/>
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
<button
type="submit"
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
aria-label="Сохранить"
title="Сохранить"
disabled={savingName || !nameDraft.trim()}
>
<Icon name="check" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label="Отмена"
title="Отмена"
onclick={() => (editingName = false)}
>
<Icon name="close" class="size-4" />
</button>
</div>
</form>
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
{:else if canRename}
<button
type="button"
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
title="Переименовать"
onclick={startEdit}
>
{p.original_filename}
</button>
{:else}
<h2 class="text-text truncate text-sm font-semibold">{p.original_filename}</h2>
{/if}
</div>
<PanelSection id="photo-info" title="Информация">
{#snippet actions()}
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
aria-label="Настройки атрибутов"
title="Настройки атрибутов"
onclick={(e) => {
e.stopPropagation();
settingsOpen = true;
}}
>
<Icon name="settings" class="size-4" />
</button>
{/snippet}
<dl class="space-y-1">
<div class={metaRow}><span>Автор</span><span class="text-text">{p.uploader.name}</span></div>
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(p.size)}</span></div>
<div class={metaRow}><span>Тип</span><span class="text-text">{p.mime_type}</span></div>
<div class={metaRow}>
<span>Разрешение</span>
<span class="text-text">{p.width}×{p.height}</span>
</div>
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
<div class={metaRow}>
<span>{row.label}</span>
<span class="text-text">{row.value}</span>
</div>
{/each}
{#if hiddenCount > 0 && !showAll}
<button
type="button"
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
aria-label="Показать все атрибуты"
title="Показать все атрибуты"
onclick={() => (showAll = true)}
>
…
</button>
{/if}
{#if p.taken_at}
<div class={metaRow}>
<span>Снято</span>
<span class="text-text">{new Date(p.taken_at).toLocaleString()}</span>
</div>
{/if}
{#if p.latitude != null && p.longitude != null}
<div class={metaRow}>
<span>Координаты</span>
<span class="text-text">{p.latitude.toFixed(5)}, {p.longitude.toFixed(5)}</span>
</div>
{/if}
<div class={metaRow}>
<span>Загружено</span>
<span class="text-text">{new Date(p.created_at).toLocaleString()}</span>
</div>
</dl>
</PanelSection>
<PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={p.id} />
</PanelSection>
</div>
{#if settingsOpen}
<PhotoInfoSettingsDialog
attributes={attributeKeys}
defaults={EXIF_LABELS}
visible={[...effectiveVisible]}
labels={prefs.photo_info?.labels ?? {}}
busy={savingPrefs}
onSave={savePreferences}
onReset={() => savePreferences({})}
onClose={() => (settingsOpen = false)}
/>
{/if}
<PhotoPanel
photo={panelPhoto}
{role}
viewer={!!photoId}
onOpen={openSelected}
onRenamed={onRenamed}
onDeleted={onDeleted}
/>
{:else if !photoId && selectedItems.length > 1}
{@render selectionActions()}
{:else if album && !photoId}
@@ -6,6 +6,10 @@
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { trash } from '$lib/stores/trash.svelte';
import type { Album } from '$lib/types';
const activeId = $derived($page.params.id ?? '');
const onTrash = $derived($page.url.pathname === '/trash');
@@ -36,9 +40,24 @@
}
}
function onContextMenu(ev: MouseEvent, album: Album) {
ev.preventDefault();
ev.stopPropagation();
const items: ContextMenuItem[] = [
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
];
contextMenu.show(ev.clientX, ev.clientY, items);
}
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
$effect(() => {
$page.url.pathname;
if (auth.isAuthenticated) trash.load();
});
</script>
<div class="flex h-full flex-col">
@@ -79,6 +98,7 @@
ondragover={(e) => onDragOver(e, album.id)}
ondragleave={() => onDragLeave(album.id)}
ondrop={(e) => onDrop(e, album.id)}
oncontextmenu={(e) => onContextMenu(e, album)}
>
<span
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
@@ -95,6 +115,7 @@
{/if}
</span>
<span class="truncate">{album.title}</span>
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
</a>
</li>
{/each}
@@ -116,6 +137,7 @@
<Icon name="delete" class="size-4 text-text-subtle" />
</span>
<span class="truncate">Корзина</span>
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
</a>
</div>
{/if}
@@ -0,0 +1,63 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { contextMenu } from '$lib/stores/context-menu.svelte';
const width = 200;
const itemH = 34;
const style = $derived(
(() => {
const pad = 8;
let x = contextMenu.x;
let y = contextMenu.y;
if (typeof window !== 'undefined') {
x = Math.min(x, window.innerWidth - width - pad);
y = Math.min(y, window.innerHeight - contextMenu.items.length * itemH - pad);
}
return `left: ${Math.max(pad, x)}px; top: ${Math.max(pad, y)}px; min-width: ${width}px;`;
})()
);
function run(item: (typeof contextMenu.items)[number]) {
contextMenu.close();
item.run();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') contextMenu.close();
}
</script>
<svelte:window
onkeydown={onKey}
onresize={() => contextMenu.close()}
onscroll={() => contextMenu.close()}
onclick={() => contextMenu.close()}
oncontextmenu={() => contextMenu.close()}
/>
{#if contextMenu.open}
<div
use:portal
class="border-border bg-bg-elevated fixed z-[70] overflow-hidden rounded-lg border py-1 shadow-lg"
style={style}
role="menu"
tabindex="-1"
oncontextmenu={(e) => e.preventDefault()}
>
{#each contextMenu.items as item (item.label)}
<button
type="button"
role="menuitem"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm {item.danger
? 'text-danger hover:bg-bg-muted'
: 'text-text hover:bg-bg-muted'}"
onclick={() => run(item)}
>
<Icon name={item.icon} class="size-4" />
{item.label}
</button>
{/each}
</div>
{/if}
+4 -1
View File
@@ -9,9 +9,11 @@
onSelect?: (index: number, event: MouseEvent) => void;
onOpen?: (index: number) => void;
onDragStart?: (index: number, event: DragEvent) => void;
onContextMenu?: (index: number, event: MouseEvent) => void;
}
let { photos, selectedIds = [], onSelect, onOpen, onDragStart }: Props = $props();
let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
$props();
const isSelected = (id: string) => selectedIds.includes(id);
</script>
@@ -37,6 +39,7 @@
onclick={(e) => onSelect?.(i, e)}
ondblclick={() => onOpen?.(i)}
ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)}
>
{#if photo.thumb_url}
<img
@@ -0,0 +1,452 @@
<script lang="ts">
import { api, photosApi, tagsApi } from '$lib/api';
import Icon from './Icon.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
import type { Photo, Preferences, Tag } from '$lib/types';
interface Props {
photo: Photo;
role: string;
viewer?: boolean;
onOpen?: () => void;
onRenamed?: (photo: Photo) => void;
onDeleted?: () => void;
}
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
let pendingDelete = $state(false);
let deleting = $state(false);
let settingsOpen = $state(false);
let showAll = $state(false);
let savingPrefs = $state(false);
const prefs = $derived(auth.preferences);
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
const visiblePrefs = $derived(prefs.photo_info?.visible);
const effectiveVisible = $derived(
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
);
const exif = $derived((photo.exif ?? {}) as Record<string, unknown>);
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
const attributeKeys = $derived(
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
)
);
$effect(() => {
// сброс временного состояния при смене фото
photo.id;
editingName = false;
nameError = '';
showAll = false;
tagQuery = '';
tagOpen = false;
});
let tagQuery = $state('');
let tagOptions = $state<Tag[]>([]);
let tagOpen = $state(false);
let tagBusy = $state(false);
const attachedTagIds = $derived(new Set((photo.tags ?? []).map((t) => t.id)));
const tagSuggestions = $derived(
tagOptions.filter((t) => !attachedTagIds.has(t.id))
);
$effect(() => {
const q = tagQuery;
if (!tagOpen) return;
const handle = setTimeout(async () => {
try {
tagOptions = (await tagsApi.list(q)).tags;
} catch {
tagOptions = [];
}
}, 180);
return () => clearTimeout(handle);
});
async function addTag(name: string) {
const n = name.trim();
if (!n || tagBusy) return;
tagBusy = true;
try {
const res = await photosApi.addTag(photo.id, n);
onRenamed?.(res.photo);
tagQuery = '';
tagOpen = false;
} catch {
/* ignore */
} finally {
tagBusy = false;
}
}
async function removeTag(tagId: string) {
try {
const res = await photosApi.removeTag(photo.id, tagId);
onRenamed?.(res.photo);
} catch {
/* ignore */
}
}
function startEdit() {
nameDraft = photo.original_filename;
nameError = '';
editingName = true;
}
async function saveName(ev: Event) {
ev.preventDefault();
if (!nameDraft.trim()) return;
savingName = true;
nameError = '';
try {
const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
method: 'PATCH',
body: { original_filename: nameDraft.trim() }
});
onRenamed?.(data.photo);
editingName = false;
} catch (e) {
nameError = (e as Error).message;
} finally {
savingName = false;
}
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
async function savePreferences(next: Preferences) {
savingPrefs = true;
try {
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
body: next
});
auth.setPreferences(res.preferences ?? {});
showAll = false;
settingsOpen = false;
} catch {
/* ignore */
} finally {
savingPrefs = false;
}
}
function requestDelete() {
if (viewer) {
viewerActions.requestDelete(photo.id);
} else {
pendingDelete = true;
}
}
async function confirmDelete() {
deleting = true;
try {
await photosApi.deleteBatch([photo.id]);
pendingDelete = false;
onDeleted?.();
} catch {
/* ignore */
} finally {
deleting = false;
}
}
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
</script>
<div>
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
bind:value={nameDraft}
use:autofocus
aria-label="Имя файла"
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
/>
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
<button
type="submit"
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
aria-label="Сохранить"
title="Сохранить"
disabled={savingName || !nameDraft.trim()}
>
<Icon name="check" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label="Отмена"
title="Отмена"
onclick={() => (editingName = false)}
>
<Icon name="close" class="size-4" />
</button>
</div>
</form>
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
{:else if canRename}
<button
type="button"
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
title="Переименовать"
onclick={startEdit}
>
{photo.original_filename}
</button>
{:else}
<h2 class="text-text truncate text-sm font-semibold">{photo.original_filename}</h2>
{/if}
</div>
<PanelSection id="photo-actions" title="Действия">
<nav class="space-y-0.5">
{#if !viewer}
<button type="button" class={navLink} onclick={() => onOpen?.()}>
<Icon name="image" class="size-4" />
Открыть (просмотр)
</button>
{/if}
{#if photo.download_url}
<a class={navLink} href={photo.download_url} target="_blank" rel="noreferrer" download>
<Icon name="download" class="size-4" />
Скачать
</a>
{/if}
<button
type="button"
class={navLink}
onclick={() => transfer.start([photo.id], null, 'move')}
>
<Icon name="arrow_back" class="size-4" />
Переместить
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start([photo.id], null, 'copy')}
>
<Icon name="content_copy" class="size-4" />
Копировать
</button>
{#if canDelete}
<button
type="button"
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
onclick={requestDelete}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{/if}
</nav>
</PanelSection>
<PanelSection id="photo-tags" title="Метки">
<div class="space-y-2">
{#if (photo.tags ?? []).length}
<div class="flex flex-wrap gap-1.5">
{#each photo.tags ?? [] as tag (tag.id)}
<span
class="border-border bg-bg-muted text-text inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
>
{tag.name}
<button
type="button"
class="text-text-muted hover:text-danger inline-flex"
aria-label="Убрать метку {tag.name}"
title="Убрать"
onclick={() => removeTag(tag.id)}
>
<Icon name="close" class="size-3" />
</button>
</span>
{/each}
</div>
{/if}
<div class="relative">
<input
bind:value={tagQuery}
onfocus={() => (tagOpen = true)}
oninput={() => (tagOpen = true)}
onblur={() => setTimeout(() => (tagOpen = false), 150)}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagQuery);
} else if (e.key === 'Escape') {
tagOpen = false;
}
}}
placeholder="Добавить метку…"
maxlength="64"
aria-label="Метки фото"
class="border-border bg-bg text-text w-full rounded-md border px-2 py-1 text-sm"
/>
{#if tagOpen && (tagSuggestions.length > 0 || tagQuery.trim())}
<div
class="border-border bg-bg-elevated absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border py-1 shadow-lg"
>
{#if tagQuery.trim()}
<button
type="button"
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
onmousedown={(e) => {
e.preventDefault();
addTag(tagQuery);
}}
>
<Icon name="add" class="size-4" />
Создать «{tagQuery.trim()}»
</button>
{/if}
{#each tagSuggestions as tag (tag.id)}
<button
type="button"
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
onmousedown={(e) => {
e.preventDefault();
addTag(tag.name);
}}
>
<Icon name="label" class="size-4 text-text-subtle" />
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</PanelSection>
<PanelSection id="photo-info" title="Информация">
{#snippet actions()}
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
aria-label="Настройки атрибутов"
title="Настройки атрибутов"
onclick={(e) => {
e.stopPropagation();
settingsOpen = true;
}}
>
<Icon name="settings" class="size-4" />
</button>
{/snippet}
<dl class="space-y-1">
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
<div class={metaRow}>
<span>Разрешение</span>
<span class="text-text">{photo.width}×{photo.height}</span>
</div>
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
<div class={metaRow}>
<span>{row.label}</span>
<span class="text-text">{row.value}</span>
</div>
{/each}
{#if hiddenCount > 0 && !showAll}
<button
type="button"
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
aria-label="Показать все атрибуты"
title="Показать все атрибуты"
onclick={() => (showAll = true)}
>
…
</button>
{/if}
{#if photo.taken_at}
<div class={metaRow}>
<span>Снято</span>
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
</div>
{/if}
{#if photo.latitude != null && photo.longitude != null}
<div class={metaRow}>
<span>Координаты</span>
<span class="text-text">{photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}</span>
</div>
{/if}
<div class={metaRow}>
<span>Загружено</span>
<span class="text-text">{new Date(photo.created_at).toLocaleString()}</span>
</div>
</dl>
</PanelSection>
<PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={photo.id} />
</PanelSection>
</div>
{#if settingsOpen}
<PhotoInfoSettingsDialog
attributes={attributeKeys}
defaults={EXIF_LABELS}
visible={[...effectiveVisible]}
labels={prefs.photo_info?.labels ?? {}}
busy={savingPrefs}
onSave={savePreferences}
onReset={() => savePreferences({})}
onClose={() => (settingsOpen = false)}
/>
{/if}
{#if pendingDelete}
<ConfirmDialog
title="Удалить фото?"
message={photo.original_filename}
confirmLabel="Удалить"
busy={deleting}
onConfirm={confirmDelete}
onClose={() => (pendingDelete = false)}
/>
{/if}
+44 -32
View File
@@ -1,19 +1,21 @@
<script lang="ts">
import { tick } from 'svelte';
import { api } from '$lib/api';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Photo } from '$lib/types';
interface Props {
photos: Photo[];
index: number;
canDelete?: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
onDelete?: (photo: Photo) => void;
}
let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props();
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
@@ -98,9 +100,7 @@
});
const topClass = $derived(
isFullscreen
? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 border-b px-2 py-0.5 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 items-center justify-between gap-1 border-b px-2 py-0.5'
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
);
const bottomClass = $derived(
isFullscreen
@@ -288,22 +288,56 @@
else if (dx < -50) next();
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const p = current;
if (!p) return;
const items: ContextMenuItem[] = [
...(p.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(p.download_url, '_blank')
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([p.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([p.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => viewerActions.requestDelete(p.id)
}
];
contextMenu.show(e.clientX, e.clientY, items);
}
const btn =
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
const dangerBtn = 'text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1';
</script>
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
<div bind:this={topEl} class={topClass}>
<div class="flex items-center gap-1">
<div class="pointer-events-auto flex items-center gap-1">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" class="size-4" />
</button>
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
</div>
<div class="flex items-center gap-1">
<div class="pointer-events-auto flex items-center gap-1">
<button
type="button"
class={btn}
@@ -316,29 +350,6 @@
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
{#if fresh?.download_url}
<a
href={fresh.download_url}
class={btn}
target="_blank"
rel="noreferrer"
aria-label="Скачать"
title="Скачать"
>
<Icon name="download" class="size-4" />
</a>
{/if}
{#if canDelete}
<button
type="button"
class={dangerBtn}
aria-label="Удалить фото"
title="Удалить фото"
onclick={() => current && onDelete?.(current)}
>
<Icon name="delete" class="size-4" />
</button>
{/if}
</div>
</div>
@@ -365,6 +376,7 @@
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
+2
View File
@@ -41,6 +41,8 @@ export const icons = {
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' },
edit: { path: '<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83l3.75 3.75z"/>' },
label: { path: '<path fill="currentColor" d="M17.63 5.84C17.27 5.33 16.67 5 16 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h11c.67 0 1.27-.33 1.63-.84L22 12z"/>' },
menu: { path: '<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>' },
grid_view: { path: '<path fill="currentColor" d="M3 3v8h8V3zm10 0v8h8V3zM3 13v8h8v-8zm10 0v8h8v-8z"/>' },
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' },
@@ -0,0 +1,21 @@
import type { Album } from '$lib/types';
class AlbumActionsStore {
renaming = $state<Album | null>(null);
deleting = $state<Album | null>(null);
openRename(album: Album) {
this.renaming = album;
}
openDelete(album: Album) {
this.deleting = album;
}
close() {
this.renaming = null;
this.deleting = null;
}
}
export const albumActions = new AlbumActionsStore();
+14
View File
@@ -40,6 +40,20 @@ class AlbumsStore {
return data.album;
}
async rename(id: string, title: string): Promise<Album> {
const data = await api<{ album: Album }>(`/albums/${id}`, {
method: 'PATCH',
body: { title }
});
this.upsert(data.album);
return data.album;
}
async destroy(id: string): Promise<void> {
await api(`/albums/${id}`, { method: 'DELETE' });
this.remove(id);
}
upsert(album: Album) {
const i = this.list.findIndex((a) => a.id === album.id);
if (i === -1) {
@@ -0,0 +1,30 @@
import type { IconName } from '$lib/icons/registry';
export interface ContextMenuItem {
label: string;
icon: IconName;
danger?: boolean;
run: () => void;
}
class ContextMenuStore {
open = $state(false);
x = $state(0);
y = $state(0);
items = $state<ContextMenuItem[]>([]);
show(x: number, y: number, items: ContextMenuItem[]) {
if (items.length === 0) return;
this.x = x;
this.y = y;
this.items = items;
this.open = true;
}
close() {
this.open = false;
this.items = [];
}
}
export const contextMenu = new ContextMenuStore();
+24
View File
@@ -0,0 +1,24 @@
import { photosApi } from '$lib/api';
import { auth } from './auth.svelte';
class TrashStore {
count = $state(0);
async load() {
if (!auth.isAuthenticated) {
this.count = 0;
return;
}
try {
this.count = (await photosApi.trashCount()).count;
} catch {
/* ignore */
}
}
reset() {
this.count = 0;
}
}
export const trash = new TrashStore();
@@ -0,0 +1,13 @@
class ViewerActions {
deleteRequested = $state<string | null>(null);
requestDelete(id: string) {
this.deleteRequested = id;
}
clear() {
this.deleteRequested = null;
}
}
export const viewerActions = new ViewerActions();
+6
View File
@@ -36,6 +36,11 @@ export interface AlbumMember {
user: User;
}
export interface Tag {
id: string;
name: string;
}
export interface Photo {
id: string;
album_id: string;
@@ -50,6 +55,7 @@ export interface Photo {
longitude?: number | null;
caption: string;
exif?: Record<string, unknown>;
tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
created_at: string;
+2
View File
@@ -4,6 +4,7 @@
import AlbumRail from '$lib/components/AlbumRail.svelte';
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
let { children } = $props();
@@ -21,3 +22,4 @@
</AppShell>
<TransferDialog />
<AlbumActionsDialogs />
+18 -1
View File
@@ -4,10 +4,23 @@
import { albums } from '$lib/stores/albums.svelte';
import { density } from '$lib/stores/density.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import type { Album } from '$lib/types';
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
ev.preventDefault();
ev.stopPropagation();
const items: ContextMenuItem[] = [
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
];
contextMenu.show(ev.clientX, ev.clientY, items);
}
</script>
{#if !auth.isAuthenticated}
@@ -44,7 +57,11 @@
>
{#each albums.list as album (album.id)}
<li>
<a href="/albums/{album.id}" class="group block">
<a
href="/albums/{album.id}"
class="group block"
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
>
<div
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
>
@@ -1,14 +1,17 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
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 { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
@@ -21,6 +24,8 @@
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 canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
@@ -86,10 +91,12 @@
} else {
photos = [photo, ...photos];
}
loadAlbum().catch(() => {});
break;
}
case 'photo.deleted': {
photos = photos.filter((p) => p.id !== data.id);
loadAlbum().catch(() => {});
break;
}
case 'photo.updated': {
@@ -177,6 +184,80 @@
uploader?.upload(ev.dataTransfer.files);
}
}
function openMenuForPhoto(i: number, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const photo = photos[i];
if (!photo) return;
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')
}
]
: []),
{
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;
} catch (e) {
error = (e as Error).message;
} finally {
deletingMenu = false;
}
}
</script>
{#if loading}
@@ -245,6 +326,7 @@
selectedIds={selection.albumId === albumId ? selection.ids : []}
onSelect={selectPhoto}
onDragStart={dragPhoto}
onContextMenu={openMenuForPhoto}
onOpen={(i) => {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
@@ -284,7 +366,19 @@
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}
@@ -4,25 +4,19 @@
import { api } from '$lib/api';
import Viewer from '$lib/components/Viewer.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
let photos = $state<Photo[]>([]);
let role = $state('');
let loading = $state(true);
let error = $state('');
let pendingDelete = $state<Photo | null>(null);
let deleting = $state(false);
const canEdit = $derived(role === 'owner' || role === 'admin');
const index = $derived(photos.findIndex((p) => p.id === photoId));
const currentPhoto = $derived(index >= 0 ? photos[index] : null);
const canDelete = $derived(
canEdit || (!!currentPhoto && currentPhoto.uploader_id === auth.user?.id)
);
$effect(() => {
const id = albumId;
@@ -32,9 +26,8 @@
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
api<{ album: Album; role: string }>(`/albums/${id}`)
])
.then(([p, a]) => {
.then(([p]) => {
photos = p.photos;
role = a.role;
})
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
@@ -49,9 +42,13 @@
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
}
async function removePhoto(photo: Photo) {
pendingDelete = photo;
}
$effect(() => {
const id = viewerActions.deleteRequested;
if (!id) return;
const photo = photos.find((p) => p.id === id);
if (photo) pendingDelete = photo;
viewerActions.clear();
});
async function confirmDelete() {
const photo = pendingDelete;
@@ -92,14 +89,7 @@
{:else}
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1">
<Viewer
{photos}
{index}
{canDelete}
onClose={close}
onIndexChange={change}
onDelete={removePhoto}
/>
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
</div>
</div>
+149 -3
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { photosApi } from '$lib/api';
import { portal } from '$lib/actions/portal';
import { photosApi, ApiError } from '$lib/api';
import { albums } from '$lib/stores/albums.svelte';
import type { Photo } from '$lib/types';
let photos = $state<Photo[]>([]);
@@ -10,6 +12,13 @@
let pending = $state<Photo | null>(null);
let busy = $state(false);
let restoring = $state<Photo | null>(null);
let targetId = $state('');
let createNew = $state(false);
let newTitle = $state('');
let restoreBusy = $state(false);
let restoreError = $state('');
async function load() {
loading = true;
error = '';
@@ -26,12 +35,47 @@
load();
});
$effect(() => {
if (restoring && !albums.loaded) albums.load();
});
async function restore(p: Photo) {
try {
await photosApi.restore(p.id);
photos = photos.filter((x) => x.id !== p.id);
} catch (e) {
error = (e as Error).message;
if (e instanceof ApiError && e.code === 'album_required') {
restoring = p;
targetId = '';
createNew = false;
newTitle = '';
restoreError = '';
} else {
error = (e as Error).message;
}
}
}
async function confirmRestore() {
const photo = restoring;
if (!photo) return;
restoreBusy = true;
restoreError = '';
try {
let id = targetId;
if (createNew) {
if (!newTitle.trim()) return;
const album = await albums.create(newTitle.trim());
id = album.id;
}
if (!id) return;
await photosApi.restore(photo.id, id);
photos = photos.filter((x) => x.id !== photo.id);
restoring = null;
} catch (e) {
restoreError = (e as Error).message;
} finally {
restoreBusy = false;
}
}
@@ -82,7 +126,9 @@
<p class="text-text truncate text-xs" title={photo.original_filename}>
{photo.original_filename}
</p>
<p class="text-text-subtle truncate text-xs">{photo.album_title ?? ''}</p>
<p class="text-text-subtle truncate text-xs">
{photo.album_title || 'Без альбома'}
</p>
<div class="flex gap-1">
<button
type="button"
@@ -119,3 +165,103 @@
onClose={() => (pending = null)}
/>
{/if}
{#if restoring}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Восстановление фото"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !restoreBusy) restoring = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !restoreBusy) restoring = null;
}}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-5 py-3">
<h2 class="text-text text-base font-semibold">Восстановить фото</h2>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
aria-label="Закрыть"
onclick={() => (restoring = null)}
>
<Icon name="close" class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
<p class="text-text-muted text-xs">
Исходный альбом удалён. Выберите альбом или создайте новый.
</p>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" class="accent-primary size-4" bind:checked={createNew} />
Создать новый альбом
</label>
{#if createNew}
<input
bind:value={newTitle}
placeholder="Название альбома"
maxlength="120"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2 text-sm"
/>
{:else if albums.list.length === 0}
<p class="text-text-subtle text-sm">Нет альбомов — создайте новый.</p>
{:else}
<ul class="max-h-56 space-y-1 overflow-y-auto">
{#each albums.list as album (album.id)}
<li>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
album.id
? 'border-primary bg-bg-muted text-text'
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
aria-pressed={targetId === album.id}
onclick={() => (targetId = album.id)}
>
<span
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
>
{#if album.cover_thumb_url}
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
{:else}
<Icon name="image" class="size-4 text-text-subtle" />
{/if}
</span>
<span class="truncate">{album.title}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if restoreError}<p class="text-danger text-sm">{restoreError}</p>{/if}
</div>
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => (restoring = null)}
>
Отмена
</button>
<button
type="button"
disabled={restoreBusy || (createNew ? !newTitle.trim() : !targetId)}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={confirmRestore}
>
{restoreBusy ? 'Восстановление…' : 'Восстановить'}
</button>
</div>
</div>
</div>
{/if}
+2
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import '../app.css';
import IconSprite from '$lib/components/IconSprite.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme.svelte';
import { density } from '$lib/stores/density.svelte';
@@ -23,3 +24,4 @@
<IconSprite />
{@render children()}
<ContextMenu />