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