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.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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user