Files
LogisicsApp_Client/Components/car/CarImageGallery.tsx
2026-06-13 17:49:48 +03:00

361 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Components/Car/CarImageGallery.tsx
// Full-screen gallery modal for viewing, uploading and deleting car images.
import { useCallback, useEffect, useRef, useState } from "react";
import { Spinner } from "../UI";
import { carService } from "../../services/car.service";
import { getStoredToken } from "../../lib/auth";
import type { Car, CarImage, ImageStage } from "../../types/car";
// ── Stage badge config ────────────────────────────────────────────────────────
const STAGE_MAP: Record<ImageStage, { label: string; color: string; bg: string }> = {
BEFORE: { label: "قبل", color: "#854D0E", bg: "#FFFBEB" },
AFTER: { label: "بعد", color: "#166534", bg: "#DCFCE7" },
GENERAL: { label: "عام", color: "#1E40AF", bg: "#EFF6FF" },
};
// ── Props ─────────────────────────────────────────────────────────────────────
interface CarImageGalleryProps {
car: Car;
onClose: () => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
const [images, setImages] = useState<CarImage[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightbox, setLightbox] = useState<CarImage | null>(null);
const [stage, setStage] = useState<ImageStage>("GENERAL");
const [sortBy, setSortBy] = useState<"asc" | "desc">("desc");
const fileRef = useRef<HTMLInputElement>(null);
// ── Fetch images ────────────────────────────────────────────────────────────
const fetchImages = useCallback(async () => {
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const res = await carService.getImages(car.id, token, { sortBy });
const raw = (res as unknown as { data: CarImage[] }).data ?? [];
setImages(raw);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل الصور");
} finally {
setLoading(false);
}
}, [car.id, sortBy]);
useEffect(() => { fetchImages(); }, [fetchImages]);
// Close on Escape
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (lightbox) setLightbox(null);
else onClose();
}
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [lightbox, onClose]);
// ── Upload handler ──────────────────────────────────────────────────────────
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
setError(null);
try {
const token = getStoredToken();
await carService.uploadImages(car.id, files, stage, token);
await fetchImages();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "فشل رفع الصور");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
// ── Delete handler ──────────────────────────────────────────────────────────
const handleDelete = async (imageId: string) => {
setDeleting(imageId);
setError(null);
try {
const token = getStoredToken();
await carService.deleteImage(imageId, token);
setImages(prev => prev.filter(img => img.id !== imageId));
if (lightbox?.id === imageId) setLightbox(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "فشل حذف الصورة");
} finally {
setDeleting(null);
}
};
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Build full image URL — uses the proxy so no CORS issues */
const imgSrc = (img: CarImage) =>
img.url ?? `/api/proxy/car-photos/${img.image}`;
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div
role="dialog" aria-modal="true" aria-label="معرض صور المركبة"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 70,
background: "rgba(15,23,42,0.7)", backdropFilter: "blur(6px)",
display: "flex", flexDirection: "column",
}}
>
{/* ── Header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1rem 1.5rem",
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
flexShrink: 0,
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
معرض الصور
</p>
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: "2px 0 0" }}>
{car.manufacturer} {car.model} {car.plateLetters} {car.plateNumber}
</h2>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{/* Sort */}
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as "asc" | "desc")}
style={{
height: 36, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 12, color: "var(--color-text-secondary)",
padding: "0 0.75rem", outline: "none",
fontFamily: "var(--font-sans)",
}}
>
<option value="desc">الأحدث أولاً</option>
<option value="asc">الأقدم أولاً</option>
</select>
{/* Stage selector for upload */}
<select
value={stage}
onChange={e => setStage(e.target.value as ImageStage)}
style={{
height: 36, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 12, color: "var(--color-text-secondary)",
padding: "0 0.75rem", outline: "none",
fontFamily: "var(--font-sans)",
}}
>
<option value="GENERAL">عام</option>
<option value="BEFORE">قبل</option>
<option value="AFTER">بعد</option>
</select>
{/* Upload button */}
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
style={{
height: 36, padding: "0 1rem",
borderRadius: "var(--radius-md)",
border: "none",
background: uploading ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: uploading ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
{uploading
? <><Spinner size="sm" className="text-white" /> جارٍ الرفع</>
: <>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
رفع صور
</>
}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
style={{ display: "none" }}
onChange={handleUpload}
/>
{/* Close */}
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 36, height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 20, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
</div>
{/* ── Error ── */}
{error && (
<div style={{ padding: "0.75rem 1.5rem", background: "#FEF2F2", borderBottom: "1px solid #FECACA", fontSize: 13, color: "#DC2626", fontWeight: 500 }}>
{error}
<button onClick={() => setError(null)} style={{ marginRight: 8, fontSize: 14, background: "none", border: "none", color: "#DC2626", cursor: "pointer" }}>×</button>
</div>
)}
{/* ── Gallery Grid ── */}
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0" }}>
<Spinner size="lg" />
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>جارٍ تحميل الصور</span>
</div>
) : images.length === 0 ? (
<div style={{ textAlign: "center", padding: "5rem 0" }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📸</div>
<p style={{ fontSize: 15, color: "var(--color-text-muted)", fontWeight: 600 }}>لا توجد صور بعد</p>
<p style={{ fontSize: 13, color: "var(--color-text-hint)", marginTop: 4 }}>
اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.
</p>
</div>
) : (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: "1rem",
}}>
{images.map(img => {
const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL;
const isDeleting = deleting === img.id;
return (
<div
key={img.id}
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
opacity: isDeleting ? 0.5 : 1,
transition: "opacity 200ms",
}}
>
{/* Image */}
<div
style={{ position: "relative", paddingBottom: "70%", cursor: "pointer" }}
onClick={() => setLightbox(img)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(img)}
alt={`صورة ${stageInfo.label}`}
style={{
position: "absolute", inset: 0,
width: "100%", height: "100%",
objectFit: "cover",
}}
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
/>
{/* Stage badge */}
<span style={{
position: "absolute", top: 8, right: 8,
borderRadius: "var(--radius-full)",
background: stageInfo.bg,
color: stageInfo.color,
padding: "0.2rem 0.625rem",
fontSize: 10, fontWeight: 700,
boxShadow: "0 1px 4px rgba(0,0,0,.12)",
}}>
{stageInfo.label}
</span>
</div>
{/* Footer */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0.6rem 0.75rem",
borderTop: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })}
</span>
<button
type="button"
onClick={() => handleDelete(img.id)}
disabled={isDeleting}
aria-label="حذف الصورة"
style={{
width: 28, height: 28,
borderRadius: "var(--radius-sm)",
border: "1px solid #FECACA",
background: "#FEF2F2",
color: "#DC2626",
cursor: isDeleting ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
{isDeleting
? <Spinner size="sm" className="text-red-600" />
: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Lightbox ── */}
{lightbox && (
<div
onClick={() => setLightbox(null)}
style={{
position: "fixed", inset: 0, zIndex: 80,
background: "rgba(0,0,0,0.9)",
display: "flex", alignItems: "center", justifyContent: "center",
padding: "1rem",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(lightbox)}
alt="عرض مكبَّر"
style={{ maxWidth: "90vw", maxHeight: "90vh", borderRadius: "var(--radius-lg)", objectFit: "contain" }}
onClick={e => e.stopPropagation()}
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
/>
<button
onClick={() => setLightbox(null)}
style={{
position: "absolute", top: 24, right: 24,
width: 40, height: 40, borderRadius: "50%",
background: "rgba(255,255,255,0.15)", border: "none",
color: "#fff", fontSize: 20, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
)}
</div>
);
}