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
+1
View File
@@ -133,6 +133,7 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/albums/:id/photos", photosHandler.List) authed.GET("/albums/:id/photos", photosHandler.List)
authed.POST("/albums/:id/photos", photosHandler.Upload) authed.POST("/albums/:id/photos", photosHandler.Upload)
authed.GET("/photos/:id", photosHandler.Get) authed.GET("/photos/:id", photosHandler.Get)
authed.PATCH("/photos/:id", photosHandler.Rename)
authed.DELETE("/photos/:id", photosHandler.Delete) authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download) authed.GET("/photos/:id/download", photosHandler.Download)
+18
View File
@@ -93,6 +93,24 @@ func (h *Handler) Get(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photo": p}) c.JSON(http.StatusOK, gin.H{"photo": p})
} }
type renameRequest struct {
OriginalFilename string `json:"original_filename" binding:"required"`
}
func (h *Handler) Rename(c *gin.Context) {
var req renameRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "original_filename is required"))
return
}
p, err := h.svc.Rename(c.Request.Context(), c.Param("id"), auth.UserID(c), req.OriginalFilename)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) Download(c *gin.Context) { func (h *Handler) Download(c *gin.Context) {
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c)) url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil { if err != nil {
+34
View File
@@ -250,6 +250,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
return url, nil return url, nil
} }
func (s *Service) Rename(ctx context.Context, photoID, userID, filename string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
own := p.UploaderID == userID
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
return nil, httpx.Forbidden("forbidden", "you cannot rename this photo")
}
name := strings.TrimSpace(filename)
if name == "" {
return nil, httpx.BadRequest("invalid_filename", "filename is required")
}
if len(name) > 255 {
name = name[:255]
}
if err := s.store.UpdateFilename(ctx, photoID, name); err != nil {
return nil, err
}
p.OriginalFilename = name
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
s.hub.Publish(p.AlbumID, "photo.updated", p)
return p, nil
}
func (s *Service) Delete(ctx context.Context, photoID, userID string) error { func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
p, err := s.store.GetByID(ctx, photoID, userID) p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil { if err != nil {
+11
View File
@@ -127,3 +127,14 @@ func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID) albumID, photoID)
} }
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+84 -8
View File
@@ -5,6 +5,7 @@
import CoverPicker from './CoverPicker.svelte'; import CoverPicker from './CoverPicker.svelte';
import Comments from './Comments.svelte'; import Comments from './Comments.svelte';
import { albums } from '$lib/stores/albums.svelte'; import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, Photo } from '$lib/types'; import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? ''); const albumId = $derived($page.params.id ?? '');
@@ -16,17 +17,20 @@
let picking = $state(false); let picking = $state(false);
let photo = $state<Photo | null>(null); 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 canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id));
$effect(() => { $effect(() => {
const id = albumId; const id = albumId;
error = ''; error = '';
if (!id || photoId) { if (!id) {
if (!id) { album = null;
album = null; role = '';
role = '';
}
return; return;
} }
api<{ album: Album; role: string }>(`/albums/${id}`) api<{ album: Album; role: string }>(`/albums/${id}`)
@@ -40,6 +44,8 @@
$effect(() => { $effect(() => {
const id = photoId; const id = photoId;
editingName = false;
nameError = '';
if (!id) { if (!id) {
photo = null; photo = null;
return; return;
@@ -49,6 +55,37 @@
.catch((e) => (error = (e as Error).message)); .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() { async function resetCover() {
if (!album) return; if (!album) return;
try { try {
@@ -66,9 +103,48 @@
{#if photoId} {#if photoId}
{#if photo} {#if photo}
<div class="space-y-4 p-4"> <div class="space-y-4 p-4">
<h2 class="text-text truncate text-sm font-semibold" title={photo.original_filename}> {#if editingName}
{photo.original_filename} <form class="relative" onsubmit={saveName}>
</h2> <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>
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div> <div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
{#if photo.taken_at} {#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[]; photos: Photo[];
index: number; index: number;
canEdit?: boolean; canEdit?: boolean;
canDelete?: boolean;
onClose: () => void; onClose: () => void;
onIndexChange: (index: number) => void; onIndexChange: (index: number) => void;
onMakeCover?: (photo: Photo) => 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 fresh = $state<Photo | null>(null);
let touchX = 0; let touchX = 0;
@@ -52,13 +63,20 @@
const btn = 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'; '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> </script>
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<div class="flex h-full flex-col"> <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"> <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"> <div class="flex items-center gap-2">
{#if canEdit} {#if canEdit}
<button <button
@@ -77,9 +95,17 @@
<span class="hidden sm:inline">Скачать</span> <span class="hidden sm:inline">Скачать</span>
</a> </a>
{/if} {/if}
<button type="button" class={btn} aria-label="Закрыть" title="Закрыть" onclick={onClose}> {#if canDelete}
<Icon name="close" /> <button
</button> type="button"
class={dangerBtn}
aria-label="Удалить фото"
title="Удалить фото"
onclick={() => current && onDelete?.(current)}
>
<Icon name="delete" class="size-4" />
</button>
{/if}
</div> </div>
</div> </div>
@@ -90,6 +90,11 @@
photos = photos.filter((p) => p.id !== data.id); photos = photos.filter((p) => p.id !== data.id);
break; break;
} }
case 'photo.updated': {
const photo = data as unknown as Photo;
photos = photos.map((p) => (p.id === photo.id ? photo : p));
break;
}
case 'like.added': { case 'like.added': {
photos = photos.map((p) => photos = photos.map((p) =>
p.id === data.photo_id p.id === data.photo_id
@@ -3,7 +3,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import Viewer from '$lib/components/Viewer.svelte'; import Viewer from '$lib/components/Viewer.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { albums } from '$lib/stores/albums.svelte'; import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, Photo } from '$lib/types'; import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? ''); const albumId = $derived($page.params.id ?? '');
@@ -14,9 +16,15 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let saved = $state(false); let saved = $state(false);
let pendingDelete = $state<Photo | null>(null);
let deleting = $state(false);
const canEdit = $derived(role === 'owner' || role === 'admin'); const canEdit = $derived(role === 'owner' || role === 'admin');
const index = $derived(photos.findIndex((p) => p.id === photoId)); 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(() => { $effect(() => {
const id = albumId; const id = albumId;
@@ -53,6 +61,36 @@
error = (e as Error).message; error = (e as Error).message;
} }
} }
async function removePhoto(photo: Photo) {
pendingDelete = photo;
}
async function confirmDelete() {
const photo = pendingDelete;
if (!photo) return;
deleting = true;
error = '';
try {
await api(`/photos/${photo.id}`, { method: 'DELETE' });
pendingDelete = null;
const remaining = photos.filter((p) => p.id !== photo.id);
if (remaining.length > 0) {
const next = remaining[Math.min(Math.max(index, 0), remaining.length - 1)];
await goto(`/albums/${albumId}/photos/${next.id}`, {
replaceState: true,
noScroll: true
});
} else {
await goto(`/albums/${albumId}`);
}
photos = remaining;
} catch (e) {
error = (e as Error).message;
} finally {
deleting = false;
}
}
</script> </script>
{#if loading} {#if loading}
@@ -74,10 +112,22 @@
{photos} {photos}
{index} {index}
{canEdit} {canEdit}
{canDelete}
onClose={close} onClose={close}
onIndexChange={change} onIndexChange={change}
onMakeCover={makeCover} onMakeCover={makeCover}
onDelete={removePhoto}
/> />
</div> </div>
</div> </div>
{#if pendingDelete}
<ConfirmDialog
title="Удалить фото?"
message={pendingDelete.original_filename}
busy={deleting}
onConfirm={confirmDelete}
onClose={() => (pendingDelete = null)}
/>
{/if}
{/if} {/if}