diff --git a/docker/frontend/src/PhotosPage.tsx b/docker/frontend/src/PhotosPage.tsx index feecfda..34efb63 100644 --- a/docker/frontend/src/PhotosPage.tsx +++ b/docker/frontend/src/PhotosPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { api, type Photo, type User } from './api'; import { useI18n } from './i18n/I18nProvider'; import { Avatar } from './ui/Avatar'; +import { DeletePhotoModal } from './ui/DeletePhotoModal'; // The member's own photo folder: everything this account has uploaded, with the // count that the studio's SAVE PHOTO button mirrors and the same 12-photo cap @@ -15,6 +16,9 @@ export function PhotosPage() { const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); + // The card whose removal is being confirmed — the row takes the photo's + // history with it, so the dialog says so and offers a download first. + const [removing, setRemoving] = useState(null); const filePick = useRef(null); useEffect(() => { @@ -43,6 +47,7 @@ export function PhotosPage() { setNote(t('photos.failed', { msg: err instanceof Error ? err.message : String(err) })); } finally { setBusy(false); + setRemoving(null); } }; @@ -132,7 +137,7 @@ export function PhotosPage() { className="btn adm-danger" data-key={`my-photo-del-${p.id}`} disabled={busy} - onClick={() => void remove(p.id)} + onClick={() => setRemoving(p)} > {t('photos.delete')} @@ -142,6 +147,16 @@ export function PhotosPage() { ) : null} + + {removing ? ( + void remove(removing.id)} + onClose={() => setRemoving(null)} + /> + ) : null} ); } diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index ea2492f..f47df1f 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -14,6 +14,7 @@ export const en: Dict = { 'nav.guest': 'Guest', 'act.undo': 'UNDO', + 'act.redo': 'REDO', 'act.reset': 'RESET', 'act.export': 'EXPORT', 'act.exporting': 'Exporting…', @@ -129,14 +130,16 @@ export const en: Dict = { 'save.photoFull': 'That is all {max} — remove one in My photos.', 'save.photoNoSource': 'Load a photo first.', 'save.photoFailed': 'Could not save the photo: {msg}', - 'myp.newOn': 'NEW SAVES: FILM STRIP', - 'myp.newOff': 'NEW SAVES: PRIVATE', - 'myp.thisOn': 'THIS PHOTO: FILM STRIP', - 'myp.thisOff': 'THIS PHOTO: PRIVATE', 'myp.empty': 'NO SAVED PHOTOS YET.', 'myp.history': 'HISTORY', 'myp.historyEmpty': 'NO EARLIER SAVES YET.', 'myp.consent': 'Allow this photo on the landing page', + 'myp.remove': 'REMOVE PHOTO', + 'del.title': 'REMOVE THIS PHOTO', + 'del.warning': 'Removing this photo also removes the {n} looks its history kept before the last saves — they cannot be recovered. Download the photo first if you want to keep it.', + 'del.download': 'DOWNLOAD FIRST', + 'del.confirm': 'DELETE PHOTO', + 'del.cancel': 'CANCEL', 'photo.contribute': 'CONTRIBUTE A PHOTO', 'photo.uploading': 'UPLOADING…', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 01dc373..55f0369 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -21,6 +21,7 @@ export const vi = { 'nav.guest': 'Khách', 'act.undo': 'HOÀN TÁC', + 'act.redo': 'LÀM LẠI', 'act.reset': 'ĐẶT LẠI', 'act.export': 'XUẤT ẢNH', 'act.exporting': 'Đang xuất…', @@ -137,14 +138,16 @@ export const vi = { 'save.photoFull': 'Đã đủ {max} ảnh — xoá bớt trong Ảnh của tôi.', 'save.photoNoSource': 'Chưa có ảnh nào để lưu.', 'save.photoFailed': 'Không lưu được ảnh: {msg}', - 'myp.newOn': 'BẢN LƯU MỚI: LÊN DẢI PHIM', - 'myp.newOff': 'BẢN LƯU MỚI: RIÊNG TƯ', - 'myp.thisOn': 'ẢNH NÀY: LÊN DẢI PHIM', - 'myp.thisOff': 'ẢNH NÀY: RIÊNG TƯ', 'myp.empty': 'CHƯA LƯU ẢNH NÀO.', 'myp.history': 'LỊCH SỬ', 'myp.historyEmpty': 'CHƯA CÓ MỐC LƯU NÀO TRƯỚC ĐÓ.', 'myp.consent': 'Cho phép ảnh này lên landing page', + 'myp.remove': 'XOÁ ẢNH', + 'del.title': 'XOÁ ẢNH NÀY', + 'del.warning': 'Xoá ảnh này sẽ xoá tất cả history đã lưu trước đó ({n} look) và không khôi phục được. Cần tải ảnh về trước khi xoá.', + 'del.download': 'TẢI VỀ TRƯỚC', + 'del.confirm': 'XOÁ ẢNH', + 'del.cancel': 'HUỶ', 'photo.contribute': 'ĐÓNG GÓP ẢNH', 'photo.uploading': 'ĐANG TẢI…', diff --git a/docker/frontend/src/ui/DeletePhotoModal.tsx b/docker/frontend/src/ui/DeletePhotoModal.tsx new file mode 100644 index 0000000..04380d9 --- /dev/null +++ b/docker/frontend/src/ui/DeletePhotoModal.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { useI18n } from '../i18n/I18nProvider'; +import { download } from '../engine/imageOps'; + +// Removing a saved photo is not like removing a note: the looks it carried +// before its last saves live in its history, and the API drops them with the +// row. So the removal asks twice — a download first, then the delete — and says +// plainly what goes. Shared by the studio's MY PHOTOS and the folder page. +export function DeletePhotoModal({ + id, + name, + historyCount, + onConfirm, + onClose, +}: { + id: number; + name: string; + historyCount: number; + onConfirm: () => void; + onClose: () => void; +}) { + const { t } = useI18n(); + const [problem, setProblem] = useState(null); + const [busy, setBusy] = useState(false); + + // The file is the clean render the folder stored, so "download first" gives + // back the same photo the delete is about to take. + const fetchFirst = async () => { + setBusy(true); + setProblem(null); + try { + const res = await fetch(`/api/photos/${id}/file`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const bytes = new Uint8Array(await res.arrayBuffer()); + const slug = name.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'photo'; + download(bytes, `${slug}.jpg`); + } catch (err) { + setProblem(t('err.generic', { msg: err instanceof Error ? err.message : String(err) })); + } finally { + setBusy(false); + } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+

{t('del.title')}

+

+ {t('del.warning', { n: historyCount })} +

+ {problem ? ( +

+ {problem} +

+ ) : null} + + + +
+
+ ); +}