fix(tradein-frontend): MapPicker focus trap + Leaflet SRI, PhotoUpload size check (#514)
This commit is contained in:
parent
9402702f32
commit
3190bbec54
2 changed files with 84 additions and 7 deletions
|
|
@ -9,6 +9,8 @@ import { API_BASE_URL } from "@/lib/api";
|
|||
const LEAFLET_VER = "1.9.4";
|
||||
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||||
|
||||
/** Подгружает Leaflet с CDN один раз, резолвит window.L. */
|
||||
|
|
@ -23,6 +25,8 @@ function loadLeaflet(): Promise<any> {
|
|||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = LEAFLET_CSS;
|
||||
link.integrity = LEAFLET_CSS_SRI;
|
||||
link.crossOrigin = "anonymous";
|
||||
link.setAttribute("data-leaflet", "1");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
|
@ -34,6 +38,8 @@ function loadLeaflet(): Promise<any> {
|
|||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = LEAFLET_JS;
|
||||
script.integrity = LEAFLET_JS_SRI;
|
||||
script.crossOrigin = "anonymous";
|
||||
script.setAttribute("data-leaflet", "1");
|
||||
script.onload = () => resolve(w.L);
|
||||
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||
|
|
@ -96,6 +102,44 @@ export function MapPicker({ onPick, onClose }: Props) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Focus management: initial focus to close button, restore on unmount, Esc closes
|
||||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
closeBtnRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
previouslyFocused.current?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Focus trap: keep Tab/Shift+Tab inside the modal
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const onDialogKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
|
|
@ -111,7 +155,12 @@ export function MapPicker({ onPick, onClose }: Props) {
|
|||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="map-picker-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
|
|
@ -131,8 +180,9 @@ export function MapPicker({ onPick, onClose }: Props) {
|
|||
borderBottom: "1px solid #e5e5e5",
|
||||
}}
|
||||
>
|
||||
<b>Выбор адреса на карте ЕКБ</b>
|
||||
<b id="map-picker-title">Выбор адреса на карте ЕКБ</b>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { getOrCreateSessionId } from "@/lib/sessionId";
|
|||
|
||||
const BASE = "/api/v1/trade-in";
|
||||
const MAX_PHOTOS = 12;
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024; // match backend `_MAX_PHOTO_BYTES`
|
||||
|
||||
interface PhotoMeta {
|
||||
id: string;
|
||||
|
|
@ -21,7 +22,7 @@ interface PhotoMeta {
|
|||
export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||||
const [photos, setPhotos] = useState<PhotoMeta[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
|
@ -40,9 +41,21 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
|||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setErrors([]); // clear stale errors on every new pick
|
||||
const sid = getOrCreateSessionId();
|
||||
const collected: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Client-side guards mirror backend filters; reject before bandwidth waste.
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
collected.push(`${file.name}: файл больше 10 МБ (${(file.size / 1024 / 1024).toFixed(1)} МБ)`);
|
||||
continue;
|
||||
}
|
||||
if (file.size === 0) {
|
||||
collected.push(`${file.name}: пустой файл`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
try {
|
||||
|
|
@ -54,11 +67,13 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
|||
headers: sid ? { "X-Session-Id": sid } : {},
|
||||
},
|
||||
);
|
||||
if (!r.ok) setError(`${file.name}: ошибка загрузки (${r.status})`);
|
||||
if (!r.ok) collected.push(`${file.name}: ошибка загрузки (${r.status})`);
|
||||
} catch {
|
||||
setError(`${file.name}: сеть недоступна`);
|
||||
collected.push(`${file.name}: сеть недоступна`);
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(collected);
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
await refresh();
|
||||
|
|
@ -102,8 +117,20 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
|||
Достигнут лимит {MAX_PHOTOS} фото.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p style={{ color: "#c0392b", fontSize: 13, marginTop: 8 }}>{error}</p>
|
||||
{errors.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
color: "#c0392b",
|
||||
fontSize: 13,
|
||||
marginTop: 8,
|
||||
paddingLeft: 18,
|
||||
listStyleType: "disc",
|
||||
}}
|
||||
>
|
||||
{errors.map((msg, i) => (
|
||||
<li key={i}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue