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:
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user