feat: update right widget preview

This commit is contained in:
2026-04-10 16:09:48 +03:00
parent fbf8232ce3
commit d380b2570f
5 changed files with 840 additions and 161 deletions

View File

@@ -0,0 +1,326 @@
import { useMemo, useRef, useState } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ReactMarkdownComponent } from "../../ReactMarkdown";
import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary";
import {
SightFrameThreeView,
ThreeViewHandle,
} from "./SightFrameThreeView";
import { MinusIcon, PlusIcon } from "./SightFrameThreeViewIcons";
import "./SightFramePreview.css";
interface ArticleMedia {
id: string;
media_type: number;
filename: string;
}
interface Article {
id: number;
heading: string;
body: string;
media: ArticleMedia[];
}
interface PreviewMediaData {
id: string;
media_type: number;
filename?: string;
}
interface SightFramePreviewProps {
sightName: string;
previewMedia: PreviewMediaData | null;
articles: Article[];
onArticleSelect: (index: number) => void;
}
// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes
function renderCurrentMedia(
media: PreviewMediaData | ArticleMedia | null,
token: string,
threeViewResetKey: number,
threeViewControlRef: React.MutableRefObject<ThreeViewHandle | null>,
onZoomIn: () => void,
onZoomOut: () => void,
onThreeViewReset: () => void
) {
if (!media) return null;
const src = `${import.meta.env.VITE_KRBL_MEDIA}${media.id}/download?token=${token}`;
const className = "sfp-sight-frame-media-item";
switch (media.media_type) {
// image (1), gif (3), sprite (4)
case 1:
case 3:
case 4:
return (
<img
src={src}
alt=""
className={className}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
);
// video
case 2:
return (
<video
muted
autoPlay
loop
playsInline
className={className}
onError={(e) => {
(e.target as HTMLVideoElement).style.display = "none";
}}
>
<source src={src} type="video/mp4" />
</video>
);
// panorama
case 5:
return (
<div
className={`${className} panorama-container`}
style={{ isolation: "isolate" }}
>
<ReactPhotoSphereViewer
src={src}
width={"100%"}
height={"100%"}
/>
</div>
);
// 3d
case 6:
return (
<div className={className} style={{ position: "relative" }}>
<ThreeViewErrorBoundary
resetKey={`sfp-${media.id}-${threeViewResetKey}`}
onReset={onThreeViewReset}
>
<SightFrameThreeView
key={`sfp-3d-${media.id}-${threeViewResetKey}`}
fileUrl={src}
width={"100%"}
height={"100%"}
controlRef={threeViewControlRef}
/>
</ThreeViewErrorBoundary>
<div className="sfp-three-d-controls-bar">
<button
type="button"
className="sfp-three-d-control-btn"
title="Уменьшить"
onPointerUp={onZoomOut}
>
<MinusIcon />
</button>
<button
type="button"
className="sfp-three-d-control-btn"
title="Увеличить"
onPointerUp={onZoomIn}
>
<PlusIcon />
</button>
</div>
</div>
);
default:
return (
<div className={className}>
<div className="sfp-sight-frame-media-placeholder">
Неподдерживаемый тип медиа
</div>
</div>
);
}
}
export function SightFramePreview({
sightName,
previewMedia,
articles,
onArticleSelect,
}: SightFramePreviewProps) {
const token = localStorage.getItem("token") ?? "";
// -1 = intro (section 0 in SightFrame)
const [selectedSection, setSelectedSection] = useState<number>(-1);
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
const threeViewControlRef = useRef<ThreeViewHandle | null>(null);
const currentArticle =
selectedSection >= 0 && selectedSection < articles.length
? articles[selectedSection]
: null;
const currentMedia: PreviewMediaData | ArticleMedia | null = currentArticle
? (currentArticle.media[0] ?? null)
: previewMedia;
const isCurrentMedia3D = currentMedia?.media_type === 6;
// Replicates processedSightName from SightFrame.jsx
const processedSightName = useMemo(() => {
if (!sightName) return sightName;
const namePattern =
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
const parts = sightName.split(namePattern);
if (parts.length > 1) {
return parts.map((part, index) => {
const initialsPattern =
/^[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+$/;
if (initialsPattern.test(part)) {
return (
<span key={index}>
<br />
{part}
</span>
);
}
return <span key={index}>{part}</span>;
});
}
return sightName;
}, [sightName]);
// Replicates titleLineHeight from SightFrame.jsx
const titleLineHeight = useMemo(() => {
if (!sightName) return "120%";
const textLength = sightName.length;
const calculatedLineHeight = Math.max(
100,
Math.min(120, 120 - (textLength / 10) * 1)
);
return `${calculatedLineHeight}%`;
}, [sightName]);
const isIntro = selectedSection === -1;
return (
<div className="sfp-sight-frame">
{/* media stack — always rendered, matches .sight-frame-media-stack */}
<div
className={`sfp-sight-frame-media-stack${isCurrentMedia3D ? " three-d-view" : ""}`}
>
{currentMedia ? (
renderCurrentMedia(
currentMedia,
token,
threeViewResetKey,
threeViewControlRef,
() => threeViewControlRef.current?.zoomIn?.(),
() => threeViewControlRef.current?.zoomOut?.(),
() => setThreeViewResetKey((k) => k + 1)
)
) : (
<div className="sfp-sight-frame-media-placeholder">
{isIntro ? "Медиа не добавлено" : "Нет медиа"}
</div>
)}
</div>
{/* content */}
<div className="sfp-sight-frame-content">
{/* title: intro-title (300px, 40px centered) or regular (24px with border) */}
<div
className={`sfp-sight-frame-title${isIntro ? " sfp-intro-title" : ""}`}
style={{ lineHeight: isIntro ? undefined : titleLineHeight }}
>
<p
style={{
whiteSpace: "normal",
wordBreak: "break-word",
overflowWrap: "break-word",
}}
>
{isIntro ? processedSightName : sightName}
</p>
</div>
{/* text body — only for article sections */}
{!isIntro && currentArticle && (
<div className="sfp-sight-frame-text-wrapper">
<div className="sfp-sight-frame-text">
<ReactMarkdownComponent value={currentArticle.body} />
</div>
</div>
)}
</div>
{/* menu — matches .sight-frame-menu */}
<div className="sfp-sight-frame-menu">
{/* back arrow — shown when not on intro */}
{!isIntro && (
<button
className="sfp-back-btn"
type="button"
onClick={() => setSelectedSection(-1)}
>
<svg
width="20"
height="25"
viewBox="0 0 20 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ display: "block" }}
>
<defs>
<linearGradient
id="sfpGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.2)" />
<stop offset="100%" stopColor="rgba(255, 255, 255, 0)" />
</linearGradient>
<clipPath id="sfpClip">
<rect
width="20"
height="25"
fill="white"
transform="translate(12.5 0.5) rotate(90)"
/>
</clipPath>
</defs>
<g clipPath="url(#sfpClip)">
<path
d="M4.03158 11.0738C4.21879 11.2087 4.34702 11.2766 4.44531 11.3747C6.93048 13.8687 9.41098 16.3665 11.8962 18.8605C12.3408 19.3067 12.5186 19.8207 12.3361 20.4339C12.0281 21.4658 10.7776 21.8393 9.95295 21.1498C9.86309 21.0743 9.78165 20.9894 9.69928 20.9064C6.85279 18.0446 4.00537 15.1817 1.15982 12.3189C0.280876 11.4341 0.281813 10.6456 1.16169 9.75982C4.04 6.86305 6.91457 3.96155 9.80786 1.07986C10.0597 0.828952 10.4144 0.619547 10.7561 0.537482C11.4019 0.382786 12.015 0.72142 12.3193 1.28644C12.6263 1.85334 12.5392 2.56079 12.0806 3.05129C11.7286 3.4286 11.3561 3.78704 10.991 4.15209C8.79601 6.35557 6.60194 8.55904 4.40599 10.7606C4.32362 10.8436 4.22721 10.9106 4.03158 11.0729L4.03158 11.0738Z"
fill="url(#sfpGradient)"
/>
</g>
</svg>
</button>
)}
{/* article nav buttons — matches .sight-frame-menu-point */}
{articles.map((article, index) => (
<button
key={article.id}
type="button"
className={`sfp-sight-frame-menu-point${selectedSection === index ? " active" : ""}`}
onClick={() => {
setSelectedSection(index);
onArticleSelect(index);
}}
>
{article.heading}
</button>
))}
</div>
</div>
);
}