web: ask before a saved photo goes
Removing a saved frame is not like removing a note: its history goes with it, and nothing on screen said so. A shared modal now names the photo, warns how many looks ride along, offers the stored file as a download first, and only then deletes — from MY PHOTOS in the studio and from the folder page alike.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { api, type Photo, type User } from './api';
|
import { api, type Photo, type User } from './api';
|
||||||
import { useI18n } from './i18n/I18nProvider';
|
import { useI18n } from './i18n/I18nProvider';
|
||||||
import { Avatar } from './ui/Avatar';
|
import { Avatar } from './ui/Avatar';
|
||||||
|
import { DeletePhotoModal } from './ui/DeletePhotoModal';
|
||||||
|
|
||||||
// The member's own photo folder: everything this account has uploaded, with the
|
// 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
|
// 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 [loaded, setLoaded] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(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<Photo | null>(null);
|
||||||
const filePick = useRef<HTMLInputElement>(null);
|
const filePick = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,6 +47,7 @@ export function PhotosPage() {
|
|||||||
setNote(t('photos.failed', { msg: err instanceof Error ? err.message : String(err) }));
|
setNote(t('photos.failed', { msg: err instanceof Error ? err.message : String(err) }));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setRemoving(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +137,7 @@ export function PhotosPage() {
|
|||||||
className="btn adm-danger"
|
className="btn adm-danger"
|
||||||
data-key={`my-photo-del-${p.id}`}
|
data-key={`my-photo-del-${p.id}`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void remove(p.id)}
|
onClick={() => setRemoving(p)}
|
||||||
>
|
>
|
||||||
{t('photos.delete')}
|
{t('photos.delete')}
|
||||||
</button>
|
</button>
|
||||||
@@ -142,6 +147,16 @@ export function PhotosPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{removing ? (
|
||||||
|
<DeletePhotoModal
|
||||||
|
id={removing.id}
|
||||||
|
name={removing.title ?? `#${removing.id}`}
|
||||||
|
historyCount={removing.history?.length ?? 0}
|
||||||
|
onConfirm={() => void remove(removing.id)}
|
||||||
|
onClose={() => setRemoving(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const en: Dict = {
|
|||||||
'nav.guest': 'Guest',
|
'nav.guest': 'Guest',
|
||||||
|
|
||||||
'act.undo': 'UNDO',
|
'act.undo': 'UNDO',
|
||||||
|
'act.redo': 'REDO',
|
||||||
'act.reset': 'RESET',
|
'act.reset': 'RESET',
|
||||||
'act.export': 'EXPORT',
|
'act.export': 'EXPORT',
|
||||||
'act.exporting': 'Exporting…',
|
'act.exporting': 'Exporting…',
|
||||||
@@ -129,14 +130,16 @@ export const en: Dict = {
|
|||||||
'save.photoFull': 'That is all {max} — remove one in My photos.',
|
'save.photoFull': 'That is all {max} — remove one in My photos.',
|
||||||
'save.photoNoSource': 'Load a photo first.',
|
'save.photoNoSource': 'Load a photo first.',
|
||||||
'save.photoFailed': 'Could not save the photo: {msg}',
|
'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.empty': 'NO SAVED PHOTOS YET.',
|
||||||
'myp.history': 'HISTORY',
|
'myp.history': 'HISTORY',
|
||||||
'myp.historyEmpty': 'NO EARLIER SAVES YET.',
|
'myp.historyEmpty': 'NO EARLIER SAVES YET.',
|
||||||
'myp.consent': 'Allow this photo on the landing page',
|
'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.contribute': 'CONTRIBUTE A PHOTO',
|
||||||
'photo.uploading': 'UPLOADING…',
|
'photo.uploading': 'UPLOADING…',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const vi = {
|
|||||||
'nav.guest': 'Khách',
|
'nav.guest': 'Khách',
|
||||||
|
|
||||||
'act.undo': 'HOÀN TÁC',
|
'act.undo': 'HOÀN TÁC',
|
||||||
|
'act.redo': 'LÀM LẠI',
|
||||||
'act.reset': 'ĐẶT LẠI',
|
'act.reset': 'ĐẶT LẠI',
|
||||||
'act.export': 'XUẤT ẢNH',
|
'act.export': 'XUẤT ẢNH',
|
||||||
'act.exporting': 'Đang xuất…',
|
'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.photoFull': 'Đã đủ {max} ảnh — xoá bớt trong Ảnh của tôi.',
|
||||||
'save.photoNoSource': 'Chưa có ảnh nào để lưu.',
|
'save.photoNoSource': 'Chưa có ảnh nào để lưu.',
|
||||||
'save.photoFailed': 'Không lưu được ảnh: {msg}',
|
'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.empty': 'CHƯA LƯU ẢNH NÀO.',
|
||||||
'myp.history': 'LỊCH SỬ',
|
'myp.history': 'LỊCH SỬ',
|
||||||
'myp.historyEmpty': 'CHƯA CÓ MỐC LƯU NÀO TRƯỚC ĐÓ.',
|
'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.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.contribute': 'ĐÓNG GÓP ẢNH',
|
||||||
'photo.uploading': 'ĐANG TẢI…',
|
'photo.uploading': 'ĐANG TẢI…',
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" data-key="del-modal">
|
||||||
|
<h2>{t('del.title')}</h2>
|
||||||
|
<p className="hint" data-key="del-warning">
|
||||||
|
{t('del.warning', { n: historyCount })}
|
||||||
|
</p>
|
||||||
|
{problem ? (
|
||||||
|
<p className="err" data-key="del-error">
|
||||||
|
{problem}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="btn" data-key="del-download" disabled={busy} onClick={() => void fetchFirst()}>
|
||||||
|
{t('del.download')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn adm-danger" data-key="del-confirm" disabled={busy} onClick={onConfirm}>
|
||||||
|
{t('del.confirm')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn ghost" data-key="del-cancel" onClick={onClose}>
|
||||||
|
{t('del.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user