Компонент PhotoUpload на result-view: выбор image/* (multiple),
загрузка в POST /estimate/{id}/photos, превью загруженных фото.
Multipart напрямую через fetch — apiFetch форсит JSON content-type.
Closes #394
130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
"use client";
|
||
|
||
/* Загрузка фото квартиры к оценке (#394). */
|
||
/* eslint-disable @next/next/no-img-element -- фото отдаёт API, next/image тут лишний */
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { API_BASE_URL } from "@/lib/api";
|
||
import { getOrCreateSessionId } from "@/lib/sessionId";
|
||
|
||
const BASE = "/api/v1/trade-in";
|
||
const MAX_PHOTOS = 12;
|
||
|
||
interface PhotoMeta {
|
||
id: string;
|
||
filename: string | null;
|
||
content_type: string;
|
||
size_bytes: number | null;
|
||
uploaded_at: string;
|
||
}
|
||
|
||
export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||
const [photos, setPhotos] = useState<PhotoMeta[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const refresh = useCallback(async () => {
|
||
try {
|
||
const r = await fetch(`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`);
|
||
if (r.ok) setPhotos((await r.json()) as PhotoMeta[]);
|
||
} catch {
|
||
/* список фото некритичен — молча игнорируем */
|
||
}
|
||
}, [estimateId]);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
async function handleFiles(files: FileList | null) {
|
||
if (!files || files.length === 0) return;
|
||
setUploading(true);
|
||
setError(null);
|
||
const sid = getOrCreateSessionId();
|
||
for (const file of Array.from(files)) {
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
try {
|
||
const r = await fetch(
|
||
`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`,
|
||
{
|
||
method: "POST",
|
||
body: form,
|
||
headers: sid ? { "X-Session-Id": sid } : {},
|
||
},
|
||
);
|
||
if (!r.ok) setError(`${file.name}: ошибка загрузки (${r.status})`);
|
||
} catch {
|
||
setError(`${file.name}: сеть недоступна`);
|
||
}
|
||
}
|
||
setUploading(false);
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
await refresh();
|
||
}
|
||
|
||
const full = photos.length >= MAX_PHOTOS;
|
||
|
||
return (
|
||
<article className="card">
|
||
<div className="card-body">
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Фото квартиры</h3>
|
||
<span style={{ color: "var(--muted)", fontSize: 13 }}>
|
||
{photos.length} / {MAX_PHOTOS}
|
||
</span>
|
||
</div>
|
||
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
disabled={uploading || full}
|
||
onChange={(e) => handleFiles(e.target.files)}
|
||
/>
|
||
|
||
{uploading && (
|
||
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 8 }}>
|
||
Загрузка…
|
||
</p>
|
||
)}
|
||
{full && !uploading && (
|
||
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 8 }}>
|
||
Достигнут лимит {MAX_PHOTOS} фото.
|
||
</p>
|
||
)}
|
||
{error && (
|
||
<p style={{ color: "#c0392b", fontSize: 13, marginTop: 8 }}>{error}</p>
|
||
)}
|
||
|
||
{photos.length > 0 && (
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||
{photos.map((p) => (
|
||
<img
|
||
key={p.id}
|
||
src={`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos/${p.id}`}
|
||
alt={p.filename ?? "фото квартиры"}
|
||
style={{
|
||
width: 96,
|
||
height: 96,
|
||
objectFit: "cover",
|
||
borderRadius: 8,
|
||
border: "1px solid var(--border, #e2e2e2)",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|