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}