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:
@@ -133,6 +133,7 @@ func (s *Server) Router() *gin.Engine {
|
||||
authed.GET("/albums/:id/photos", photosHandler.List)
|
||||
authed.POST("/albums/:id/photos", photosHandler.Upload)
|
||||
authed.GET("/photos/:id", photosHandler.Get)
|
||||
authed.PATCH("/photos/:id", photosHandler.Rename)
|
||||
authed.DELETE("/photos/:id", photosHandler.Delete)
|
||||
authed.GET("/photos/:id/download", photosHandler.Download)
|
||||
|
||||
|
||||
@@ -93,6 +93,24 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
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) {
|
||||
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
|
||||
@@ -250,6 +250,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
|
||||
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 {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -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`,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
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}>
|
||||
{#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}
|
||||
</h2>
|
||||
</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>
|
||||
@@ -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">
|
||||
<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" />
|
||||
{#if canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class={dangerBtn}
|
||||
aria-label="Удалить фото"
|
||||
title="Удалить фото"
|
||||
onclick={() => current && onDelete?.(current)}
|
||||
>
|
||||
<Icon name="delete" class="size-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
photos = photos.filter((p) => p.id !== data.id);
|
||||
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': {
|
||||
photos = photos.map((p) =>
|
||||
p.id === data.photo_id
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import Viewer from '$lib/components/Viewer.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.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 ?? '');
|
||||
@@ -14,9 +16,15 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let saved = $state(false);
|
||||
let pendingDelete = $state<Photo | null>(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
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(() => {
|
||||
const id = albumId;
|
||||
@@ -53,6 +61,36 @@
|
||||
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>
|
||||
|
||||
{#if loading}
|
||||
@@ -74,10 +112,22 @@
|
||||
{photos}
|
||||
{index}
|
||||
{canEdit}
|
||||
{canDelete}
|
||||
onClose={close}
|
||||
onIndexChange={change}
|
||||
onMakeCover={makeCover}
|
||||
onDelete={removePhoto}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pendingDelete}
|
||||
<ConfirmDialog
|
||||
title="Удалить фото?"
|
||||
message={pendingDelete.original_filename}
|
||||
busy={deleting}
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => (pendingDelete = null)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user