Files
RecipesCam/docker/frontend/src/PhotosPage.tsx
T
3dtours 78fe2b097b 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.
2026-09-18 15:47:06 +07:00

163 lines
5.6 KiB
TypeScript

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
// the API enforces. No router — main.tsx picks it by pathname.
const MAX_PHOTOS = 12;
export function PhotosPage() {
const { t } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
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);
useEffect(() => {
api
.me()
.then((r) => setUser(r.user))
.catch(() => undefined)
.finally(() => setLoaded(true));
}, []);
const reload = useCallback(
() => api.listMyPhotos().then((r) => setPhotos(r.photos)).catch(() => setPhotos([])),
[],
);
useEffect(() => {
if (user) void reload();
}, [user, reload]);
const run = async (fn: () => Promise<void>) => {
setBusy(true);
setNote(null);
try {
await fn();
} catch (err) {
setNote(t('photos.failed', { msg: err instanceof Error ? err.message : String(err) }));
} finally {
setBusy(false);
setRemoving(null);
}
};
const add = (file: File) =>
run(async () => {
await api.uploadPhoto(file);
await reload();
});
const remove = (id: number) =>
run(async () => {
await api.deletePhoto(id);
setPhotos((prev) => prev.filter((p) => p.id !== id));
setNote(t('photos.deleted'));
});
const full = photos.length >= MAX_PHOTOS;
return (
<div className="adm">
<header className="adm-bar">
<a className="adm-brand" href="/">
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
<span>Recipes<em>Cam</em></span>
</a>
<b className="adm-title">{t('photos.title')}</b>
<span className="adm-spacer" />
<span className="hint" data-key="photos-count">{t('photos.count', { n: photos.length, max: MAX_PHOTOS })}</span>
<Avatar user={user} />
<a className="btn ghost" data-key="photos-home" href="/">{t('nav.back')}</a>
</header>
{note ? <p className="hint adm-note">{note}</p> : null}
{loaded && !user ? (
<p className="adm-note">
<a className="btn primary" href="/?auth=login">{t('nav.login')}</a>
</p>
) : null}
{user ? (
<div className="adm-frame" data-key="photos-frame">
<div className="adm-pane">
<p className="hint adm-sub">{t('photos.hint', { max: MAX_PHOTOS })}</p>
{full ? <p className="hint adm-sub" data-key="photos-full">{t('photos.full', { max: MAX_PHOTOS })}</p> : null}
<div className="chip-row" style={{ marginBottom: 12 }}>
<button
type="button"
className="btn primary"
data-key="photos-add"
disabled={busy || full}
onClick={() => filePick.current?.click()}
>
{busy ? t('photo.uploading') : t('photos.add')}
</button>
<input
ref={filePick}
type="file"
hidden
accept="image/jpeg,image/png,image/webp"
data-key="photos-add-input"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) void add(file);
}}
/>
</div>
{photos.length === 0 ? <p className="hint adm-note">{t('photos.empty')}</p> : null}
<div className="adm-grid">
{photos.map((p) => (
<article className="adm-card" key={p.id} data-key={`my-photo-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<div className="adm-meta">
{p.tag || p.title || p.meta ? (
<>
{p.tag ? <b>{p.tag}</b> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
</>
) : (
<span className="hint">{t('photos.noLabels')}</span>
)}
<span>#{p.id} · {new Date(p.createdAt).toLocaleString()}</span>
</div>
<button
type="button"
className="btn adm-danger"
data-key={`my-photo-del-${p.id}`}
disabled={busy}
onClick={() => setRemoving(p)}
>
{t('photos.delete')}
</button>
</article>
))}
</div>
</div>
</div>
) : 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>
);
}