246 lines
6.6 KiB
TypeScript
246 lines
6.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
import { useObjectPhotos } from "@/lib/analytics-api";
|
||
|
||
const INITIAL_LIMIT = 24;
|
||
|
||
export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
||
const { data, isLoading } = useObjectPhotos(objId, 200);
|
||
const [active, setActive] = useState<string | null>(null);
|
||
const [showAll, setShowAll] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") setActive(null);
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [active]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
||
gap: 8,
|
||
minHeight: 320,
|
||
}}
|
||
>
|
||
{Array.from({ length: 12 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
aspectRatio: "4/3",
|
||
background: "#f3f4f6",
|
||
borderRadius: 6,
|
||
animation: "pulse 1.4s ease-in-out infinite",
|
||
}}
|
||
/>
|
||
))}
|
||
<style>{`@keyframes pulse {0%,100%{opacity:1}50%{opacity:.55}}`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const photos = (data ?? []).filter((p) => p.photo_url);
|
||
if (photos.length === 0) {
|
||
return (
|
||
<div style={{ color: "#9ca3af", minHeight: 80 }}>Фото отсутствуют.</div>
|
||
);
|
||
}
|
||
|
||
const visible = showAll ? photos : photos.slice(0, INITIAL_LIMIT);
|
||
const activeIdx = active
|
||
? photos.findIndex((p) => p.obj_file_id === active)
|
||
: -1;
|
||
const activePhoto = activeIdx >= 0 ? photos[activeIdx] : null;
|
||
|
||
const move = (delta: number) => {
|
||
if (activeIdx < 0) return;
|
||
const next = (activeIdx + delta + photos.length) % photos.length;
|
||
setActive(photos[next].obj_file_id);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{visible.map((p, i) => (
|
||
<button
|
||
key={p.obj_file_id}
|
||
onClick={() => setActive(p.obj_file_id)}
|
||
style={{
|
||
padding: 0,
|
||
border: "1px solid #e6e8ec",
|
||
borderRadius: 6,
|
||
overflow: "hidden",
|
||
cursor: "zoom-in",
|
||
background: "#f3f4f6",
|
||
aspectRatio: "4/3",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={p.thumb_url}
|
||
alt={p.photo_name ?? "фото прогресса"}
|
||
loading={i < 6 ? "eager" : "lazy"}
|
||
decoding="async"
|
||
style={{
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "cover",
|
||
position: "absolute",
|
||
inset: 0,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
background: "rgba(0,0,0,0.55)",
|
||
color: "#fff",
|
||
fontSize: 11,
|
||
padding: "3px 6px",
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{p.period_dt?.slice(0, 7) ?? "—"}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{photos.length > INITIAL_LIMIT && !showAll ? (
|
||
<div style={{ textAlign: "center", marginTop: 12 }}>
|
||
<button
|
||
onClick={() => setShowAll(true)}
|
||
style={{
|
||
padding: "6px 14px",
|
||
fontSize: 13,
|
||
border: "1px solid #d1d5db",
|
||
borderRadius: 16,
|
||
background: "#fff",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
Показать ещё {photos.length - INITIAL_LIMIT}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{activePhoto ? (
|
||
<div
|
||
onClick={() => setActive(null)}
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
background: "rgba(0,0,0,0.9)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
zIndex: 1000,
|
||
cursor: "zoom-out",
|
||
}}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={activePhoto.full_url}
|
||
alt={activePhoto.photo_name ?? ""}
|
||
style={{
|
||
maxWidth: "92vw",
|
||
maxHeight: "92vh",
|
||
objectFit: "contain",
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
move(-1);
|
||
}}
|
||
aria-label="Предыдущее"
|
||
style={navBtnStyle("left")}
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
move(1);
|
||
}}
|
||
aria-label="Следующее"
|
||
style={navBtnStyle("right")}
|
||
>
|
||
›
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setActive(null);
|
||
}}
|
||
aria-label="Закрыть"
|
||
style={{
|
||
position: "absolute",
|
||
top: 16,
|
||
right: 16,
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
border: "none",
|
||
background: "rgba(255,255,255,0.15)",
|
||
color: "#fff",
|
||
fontSize: 20,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
bottom: 16,
|
||
left: 16,
|
||
right: 16,
|
||
color: "#fff",
|
||
textAlign: "center",
|
||
fontSize: 13,
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{activeIdx + 1} / {photos.length} ·{" "}
|
||
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function navBtnStyle(side: "left" | "right"): React.CSSProperties {
|
||
return {
|
||
position: "absolute",
|
||
top: "50%",
|
||
[side]: 16,
|
||
transform: "translateY(-50%)",
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
border: "none",
|
||
background: "rgba(255,255,255,0.15)",
|
||
color: "#fff",
|
||
fontSize: 28,
|
||
lineHeight: 1,
|
||
cursor: "pointer",
|
||
};
|
||
}
|