feat: photo rename and viewer delete with confirmation

Backend:
- add PATCH /api/photos/:id to rename original_filename (uploader/owner/admin)
- emit photo.updated SSE event

Frontend:
- inline filename editor in the right panel
- viewer: back button (arrow_back) on the left, delete on the right
- delete confirmed via a modal dialog; filmstrip updates after deletion
This commit is contained in:
iu
2026-10-10 18:50:16 +03:00
parent 1f0e909ffd
commit aa396bdb85
9 changed files with 296 additions and 13 deletions
+84 -8
View File
@@ -5,6 +5,7 @@
import CoverPicker from './CoverPicker.svelte';
import Comments from './Comments.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
@@ -16,17 +17,20 @@
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 canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id));
$effect(() => {
const id = albumId;
error = '';
if (!id || photoId) {
if (!id) {
album = null;
role = '';
}
if (!id) {
album = null;
role = '';
return;
}
api<{ album: Album; role: string }>(`/albums/${id}`)
@@ -40,6 +44,8 @@
$effect(() => {
const id = photoId;
editingName = false;
nameError = '';
if (!id) {
photo = null;
return;
@@ -49,6 +55,37 @@
.catch((e) => (error = (e as Error).message));
});
function startEdit() {
if (!photo) return;
nameDraft = photo.original_filename;
nameError = '';
editingName = true;
}
async function saveName(ev: Event) {
ev.preventDefault();
if (!photo || !nameDraft.trim()) return;
savingName = true;
nameError = '';
try {
const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
method: 'PATCH',
body: { original_filename: nameDraft.trim() }
});
photo = data.photo;
editingName = false;
} catch (e) {
nameError = (e as Error).message;
} finally {
savingName = false;
}
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
async function resetCover() {
if (!album) return;
try {
@@ -66,9 +103,48 @@
{#if photoId}
{#if photo}
<div class="space-y-4 p-4">
<h2 class="text-text truncate text-sm font-semibold" title={photo.original_filename}>
{photo.original_filename}
</h2>
{#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 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>
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
{#if photo.taken_at}
@@ -0,0 +1,62 @@
<script lang="ts">
import Icon from './Icon.svelte';
interface Props {
title: string;
message?: string;
confirmLabel?: string;
busy?: boolean;
onConfirm: () => void;
onClose: () => void;
}
let {
title,
message,
confirmLabel = 'Удалить',
busy = false,
onConfirm,
onClose
}: Props = $props();
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={onKey}
>
<div class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5">
<h2 class="text-text text-lg font-semibold">{title}</h2>
{#if message}<p class="text-text-muted break-words text-sm">{message}</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={onClose}
>
Отмена
</button>
<button
type="button"
disabled={busy}
class="bg-danger text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={onConfirm}
>
<Icon name="delete" class="size-4" />
{busy ? 'Удаление…' : confirmLabel}
</button>
</div>
</div>
</div>
+31 -5
View File
@@ -7,12 +7,23 @@
photos: Photo[];
index: number;
canEdit?: boolean;
canDelete?: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
onMakeCover?: (photo: Photo) => void;
onDelete?: (photo: Photo) => void;
}
let { photos, index, canEdit = false, onClose, onIndexChange, onMakeCover }: Props = $props();
let {
photos,
index,
canEdit = false,
canDelete = false,
onClose,
onIndexChange,
onMakeCover,
onDelete
}: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
@@ -52,13 +63,20 @@
const btn =
'border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm';
const dangerBtn =
'border-border text-danger hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm';
</script>
<svelte:window onkeydown={onKey} />
<div class="flex h-full flex-col">
<div class="border-border flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
<div class="flex items-center gap-2">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" />
</button>
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
</div>
<div class="flex items-center gap-2">
{#if canEdit}
<button
@@ -77,9 +95,17 @@
<span class="hidden sm:inline">Скачать</span>
</a>
{/if}
<button type="button" class={btn} aria-label="Закрыть" title="Закрыть" onclick={onClose}>
<Icon name="close" />
</button>
{#if canDelete}
<button
type="button"
class={dangerBtn}
aria-label="Удалить фото"
title="Удалить фото"
onclick={() => current && onDelete?.(current)}
>
<Icon name="delete" class="size-4" />
</button>
{/if}
</div>
</div>