From aa396bdb8561564150e4733e4763f80caf108bc6 Mon Sep 17 00:00:00 2001 From: Iu Date: Sat, 10 Oct 2026 18:50:16 +0300 Subject: [PATCH] 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 --- backend/internal/app/app.go | 1 + backend/internal/photos/handlers.go | 18 ++++ backend/internal/photos/service.go | 34 +++++++ backend/internal/photos/store.go | 11 +++ frontend/src/lib/components/AlbumPanel.svelte | 92 +++++++++++++++++-- .../src/lib/components/ConfirmDialog.svelte | 62 +++++++++++++ frontend/src/lib/components/Viewer.svelte | 36 +++++++- .../src/routes/(app)/albums/[id]/+page.svelte | 5 + .../albums/[id]/photos/[photoId]/+page.svelte | 50 ++++++++++ 9 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/components/ConfirmDialog.svelte diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7330727..0107107 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 4d0190c..671bfbe 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -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 { diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 9f3482d..425e22c 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -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 { diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 848e630..397b5db 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -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 +} diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index 40f51e2..976c46b 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -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(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}
-

- {photo.original_filename} -

+ {#if editingName} +
+ +
+ + +
+
+ {#if nameError}

{nameError}

{/if} + {:else if canRename} + + {:else} +

{photo.original_filename}

+ {/if}
Автор{photo.uploader.name}
{#if photo.taken_at} diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..862640f --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index d9b1d41..e4dca15 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -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(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';
- {index + 1} / {photos.length} +
+ + {index + 1} / {photos.length} +
{#if canEdit} + {#if canDelete} + + {/if}
diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte index 4b4d884..1cba6ae 100644 --- a/frontend/src/routes/(app)/albums/[id]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -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 diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte index ba1dd48..b74a798 100644 --- a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte @@ -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(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; + } + } {#if loading} @@ -74,10 +112,22 @@ {photos} {index} {canEdit} + {canDelete} onClose={close} onIndexChange={change} onMakeCover={makeCover} + onDelete={removePhoto} />
+ + {#if pendingDelete} + (pendingDelete = null)} + /> + {/if} {/if}