Files
WhiteNightsAdminPanel/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx

311 lines
8.6 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.
import React, { useMemo, useRef, useState } from "react";
import subtractHomeIcon from "./subtract-home.svg";
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;
previewFontSize?: number;
selectedSection: number;
onSectionChange: (section: 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,
previewFontSize,
selectedSection,
onSectionChange,
}: SightFramePreviewProps) {
const token = localStorage.getItem("token") ?? "";
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;
// Handle \n line breaks
if (sightName.includes("\n")) {
return sightName.split("\n").map((line, i) => (
<React.Fragment key={i}>
{i > 0 && <br />}
{line}
</React.Fragment>
));
}
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 calculatedLineHeight = Math.max(
100,
Math.min(120, 120 - (sightName.replace(/\n/g, "").length / 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,
...(isIntro && previewFontSize != null
? { fontSize: `${previewFontSize}px` }
: {}),
}}
>
<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={() => onSectionChange(-1)}
>
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
</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={() => {
onSectionChange(index);
onArticleSelect(index);
}}
>
{article.heading}
</button>
))}
</div>
</div>
);
}