diff --git a/src/pages/media/ModelViewer/index.tsx b/src/pages/media/ModelViewer/index.tsx index 36d0442..ce75996 100644 --- a/src/pages/media/ModelViewer/index.tsx +++ b/src/pages/media/ModelViewer/index.tsx @@ -3,13 +3,14 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; type ModelViewerProps = { fileUrl: string; + height?: string; }; -export const ModelViewer = ({ fileUrl }: ModelViewerProps) => { +export const ModelViewer = ({ fileUrl, height }: ModelViewerProps) => { const { scene } = useGLTF(fileUrl); return ( - + diff --git a/src/pages/media/show.tsx b/src/pages/media/show.tsx index a12e0c6..c5dc884 100644 --- a/src/pages/media/show.tsx +++ b/src/pages/media/show.tsx @@ -94,7 +94,7 @@ export const MediaShow = () => { src={`${import.meta.env.VITE_KRBL_MEDIA}${ record?.id }/download?token=${token}`} - width={"100%px"} + width={"100%"} height={"80vh"} /> )} diff --git a/src/pages/sight/edit.tsx b/src/pages/sight/edit.tsx index a71c7f2..994c9ca 100644 --- a/src/pages/sight/edit.tsx +++ b/src/pages/sight/edit.tsx @@ -21,6 +21,8 @@ import { observer } from "mobx-react-lite"; import { languageStore } from "../../store/LanguageStore"; import axios from "axios"; import { LanguageSwitch } from "../../components/LanguageSwitch/index"; +import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; +import { ModelViewer } from "../media/ModelViewer"; function a11yProps(index: number) { return { @@ -109,9 +111,11 @@ export const SightEdit = observer(() => { }); const [mediaFile, setMediaFile] = useState<{ src: string; + media_type: number; filename: string; }>({ src: "", + media_type: 1, filename: "", }); @@ -249,18 +253,20 @@ export const SightEdit = observer(() => { src: `${import.meta.env.VITE_KRBL_MEDIA}${ media.id }/download?token=${localStorage.getItem(TOKEN_KEY)}`, + media_type: media.media_type, filename: media.filename, }); - console.log(media); } else { setMediaFile({ src: "", + media_type: 1, filename: "", }); // или другой дефолт } } catch (error) { setMediaFile({ src: "", + media_type: 1, filename: "", }); // или обработка ошибки } @@ -360,160 +366,6 @@ export const SightEdit = observer(() => { - - - - - - - - - type="edit" - parentId={sightId!} - dragAllowed={true} - setItemsParent={setLinkedArticles} - parentResource="sight" - fields={articleFields} - childResource="article" - title="статьи" - /> - - - - - - - theme.palette.mode === "dark" ? "background.paper" : "#fff", - }} - > - - Предпросмотр - - - - {mediaFile.src && ( - <> - {mediaFile.filename.endsWith(".mp4") ? ( - - {/* Водяные знаки */} - - - {selectedArticle && ( - - {selectedArticle.heading} - - )} - - {selectedArticle && ( - - {selectedArticle.body} - - )} - - {/* Координаты */} - - {linkedArticles.map((article, index) => ( - setSelectedArticleIndex(index)} - sx={{ - cursor: "pointer", - bgcolor: - selectedArticleIndex === index - ? "primary.main" - : "transparent", - color: selectedArticleIndex === index ? "white" : "inherit", - p: 1, - borderRadius: 1, - }} - > - - {article.heading} - - - ))} - - - - - - { + + + + + + + + + + + type="edit" + parentId={sightId!} + dragAllowed={true} + setItemsParent={setLinkedArticles} + parentResource="sight" + fields={articleFields} + childResource="article" + title="статьи" + /> + + + + + + + theme.palette.mode === "dark" ? "background.paper" : "#fff", + }} + > + + Предпросмотр + + + + {mediaFile && mediaFile.src && mediaFile.media_type === 1 && ( + {mediaFile.filename} + )} + + {mediaFile && mediaFile.media_type === 2 && ( + + {/* Водяные знаки */} + + + {selectedArticle && ( + + {selectedArticle.heading} + + )} + + {selectedArticle && ( + + {selectedArticle.body} + + )} + {selectedArticle && ( + + {selectedArticle.body} + + )} + {selectedArticle && ( + + {selectedArticle.body} + + )} + + + + {linkedArticles.map((article, index) => ( + setSelectedArticleIndex(index)} + sx={{ + cursor: "pointer", + bgcolor: + selectedArticleIndex === index + ? "primary.main" + : "transparent", + color: selectedArticleIndex === index ? "white" : "inherit", + p: 1, + borderRadius: 1, + }} + > + + {article.heading} + + + ))} + + + + + { autoComplete="off" > + ( + + option.id === field.value && option.media_type === 3 + ) || null + } + onChange={(_, value) => { + field.onChange(value?.id || ""); + }} + getOptionLabel={(item) => { + return item ? item.media_name : ""; + }} + isOptionEqualToValue={(option, value) => { + return option.id === value?.id; + }} + filterOptions={(options, { inputValue }) => { + return options.filter((option) => + option.media_name + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + { Предпросмотр + {thumbnailPreview && ( + + + Логотип достопримечательности: + + + + )} + {/* Водяные знаки */} , "title" | "content"> { + img: string; + title: LocalizedString; + subtitle: LocalizedString; + content: LocalizedString; +} + +export function AttractionShortPreview({ + img, + title, + subtitle, + content, + className, + ...props +}: AttractionShortPreviewProps) { + const localizeText = useServerLocalization(); + + return ( +
+ {img && ( + {localizeText(title)} + )} + + +
+

{localizeText(title)}

+ +
+ {localizeText(subtitle)} +
+ +

+

+
+
+ ); +} diff --git a/src/preview/components/AttractionWidget/AttractionWidget.css b/src/preview/components/AttractionWidget/AttractionWidget.css new file mode 100644 index 0000000..3ae3c4e --- /dev/null +++ b/src/preview/components/AttractionWidget/AttractionWidget.css @@ -0,0 +1,126 @@ +.widget-container { + width: 545px; + height: var(--attraction-widget-container-height, 100%); + max-height: calc(100% - 90px); + color: #ffffff; + background: #806c59; + border: 2px solid #806c59; + border-radius: 10px; +} + +.widget-content { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; +} + +.widget-slide { + position: relative; + display: none; + top: 0; + left: 0; + flex-direction: column; + justify-content: flex-start; + align-items: center; + width: 100%; +} + +.widget-slide.active, +.widget-slide.preview { + display: flex; + flex: 1; + overflow: auto; +} + +.widget-media { + width: 100%; + height: auto; + max-height: 644px; +} + +.view-container { + border-radius: 8px 8px 0 0; +} + +.widget-header { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), + rgba(179, 165, 152, 0.4); + box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12); + width: 100%; + padding: 9px 16px; + font-weight: 700; + font-size: 24px; + line-height: 120%; +} + +.widget-text { + width: 100%; + align-self: self-start; + padding: 16px; + font-weight: 400; + font-size: 18px; + line-height: 150%; /* or 27px */ + opacity: 0; + transition: opacity 0.5s ease-in-out; + user-select: none; + word-break: break-word; + white-space: pre-wrap; +} + +.widget-text p { + margin: 0; +} + +.widget-text.preview { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-weight: 700; + font-size: 48px; + line-height: 120%; + text-align: center; +} + +.widget-text.active { + opacity: 1; +} + +.widget-titles { + display: flex; + height: 50px; + justify-content: space-evenly; + align-items: center; + width: 100%; + margin: 5px 0 0 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), + rgba(179, 165, 152, 0.4); + box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12); + border-radius: 0 0 10px 10px; + padding: 12px 0; +} + +.widget-title { + font-weight: 400; + font-size: 18px; + line-height: 21px; + cursor: pointer; + user-select: none; + width: 100px; + text-align: center; +} + +.widget-title.active { + font-weight: bold; + text-decoration: underline; + text-underline-offset: 5px; +} + +.widget-title.preview { + display: none; +} diff --git a/src/preview/components/AttractionWidget/AttractionWidget.tsx b/src/preview/components/AttractionWidget/AttractionWidget.tsx new file mode 100644 index 0000000..b682909 --- /dev/null +++ b/src/preview/components/AttractionWidget/AttractionWidget.tsx @@ -0,0 +1,114 @@ +import React, { HTMLAttributes, useEffect, useState } from "react"; +import { useServerLocalization } from "@mt/i18n"; +import cn from "classnames"; +import { useSwipeable } from "react-swipeable"; +import { ArticleBase } from "@mt/common-types"; +import "./AttractionWidget.css"; +import { usePrevious } from "@mt/utils"; +import { AttractionMedia } from "./media/AttractionMedia"; +import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper"; + +export interface AttractionsWidgetProps extends HTMLAttributes { + articles: ArticleBase[]; + isIdleMode: boolean; + isPreviewOnly?: boolean; +} + +export function AttractionWidget({ + articles, + isIdleMode, + isPreviewOnly = false, + ...props +}: AttractionsWidgetProps) { + const [activeIndex, setActiveIndex] = useState(0); + const prevArticles = usePrevious(articles) || []; + const localizeText = useServerLocalization(); + + const swipeHandlers = useSwipeable({ + onSwipedLeft: ({ event }) => { + event.preventDefault(); + setActiveIndex((activeIndex) => (activeIndex + 1) % articles.length); + }, + onSwipedRight: ({ event }) => { + event.preventDefault(); + setActiveIndex( + (activeIndex) => (activeIndex - 1 + articles.length) % articles.length + ); + }, + swipeDuration: 500, + preventScrollOnSwipe: true, + trackMouse: true, + }); + + const handleClick = (index: number) => { + setActiveIndex(index); + document.querySelector(".widget-text.active")!.scrollTop = 0; + }; + + useEffect(() => setActiveIndex(activeIndex), [activeIndex]); + + useEffect(() => { + if ( + !isPreviewOnly && + (isIdleMode || JSON.stringify(prevArticles) !== JSON.stringify(articles)) + ) { + setActiveIndex(0); + } + + // admin specific case: during edit we removed active article + if (prevArticles?.length > articles?.length) { + setActiveIndex(0); + } + }, [isPreviewOnly, isIdleMode, articles]); + + return ( +
+
+ {articles?.map((article, index) => ( +
handleClick(index)} + > +
+ +
+ + {index !== 0 && ( +
+ {localizeText(articles[0].text)} +
+ )} + + +
+ +
+ ))} + +
+ {articles?.map((article, index) => ( +
handleClick(index)} + > + {localizeText(article.name)} +
+ ))} +
+
+
+ ); +} diff --git a/src/preview/components/AttractionWidget/media/AttractionMedia.css b/src/preview/components/AttractionWidget/media/AttractionMedia.css new file mode 100644 index 0000000..34774e4 --- /dev/null +++ b/src/preview/components/AttractionWidget/media/AttractionMedia.css @@ -0,0 +1,52 @@ +.widget-image, +.widget-video, +.widget-3d-model { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + display: block; + border-radius: 8px 8px 0 0; +} + +.widget-3d-model { + height: 350px; +} + +.widget-media__wrapper { + position: relative; + /*TODO: it worth to investigate it further... quite weird behavior of */ + box-sizing: content-box !important; +} + +.fullscreen-photo-sphere-btn, +.fullscreen-3d-btn { + width: 20px; + height: 20px; + position: absolute; + right: 10px; + bottom: 10px; + cursor: pointer; + z-index: 100; + opacity: 0.7; +} + +.media-with-watermark { + position: relative; +} + +.watermark { + position: absolute; + top: 10px; + left: 10px; + width: 50px; + height: auto; +} + +.psv-autorotate-button { + display: block !important; +} + +.psv-menu-button { + display: none !important; +} diff --git a/src/preview/components/AttractionWidget/media/AttractionMedia.tsx b/src/preview/components/AttractionWidget/media/AttractionMedia.tsx new file mode 100644 index 0000000..a0f9b32 --- /dev/null +++ b/src/preview/components/AttractionWidget/media/AttractionMedia.tsx @@ -0,0 +1,36 @@ +import { Media } from "@mt/common-types"; +import { ImageMedia } from "./ImageMedia"; +import { VideoMedia } from "./VideoMedia"; +import { PhotoSphereMedia } from "./PhotoSphereMedia"; +import { Object3DMedia } from "./Object3DMedia"; +import { memo } from "react"; + +export const AttractionMedia = memo( + ({ media }: { media: Media }) => { + const { type, url, watermarkUrl } = media; + + if (!url) return null; + + switch (type) { + case "IMAGE": + return ( + + ); + case "VIDEO": + return ; + case "PHOTO_SPHERE": + return ; + case "OBJECT_3D": + return ; + default: + return null; + } + }, + ({ media }, { media: newMedia }) => { + return ( + media.url === newMedia.url && + media.watermarkUrl === newMedia.watermarkUrl && + media.type === newMedia.type + ); + } +); diff --git a/src/preview/components/AttractionWidget/media/ImageMedia.tsx b/src/preview/components/AttractionWidget/media/ImageMedia.tsx new file mode 100644 index 0000000..94f4934 --- /dev/null +++ b/src/preview/components/AttractionWidget/media/ImageMedia.tsx @@ -0,0 +1,25 @@ +import cn from 'classnames'; +import React from 'react'; + +import './AttractionMedia.css'; + +interface ImageMediaProps { + url: string; + alt: string; + watermarkUrl?: string; +} + +export const ImageMedia = ({ url, alt, watermarkUrl }: ImageMediaProps) => ( + <> + {alt} + {watermarkUrl && ( + Watermark + )} + +); diff --git a/src/preview/components/AttractionWidget/media/Object3DMedia.tsx b/src/preview/components/AttractionWidget/media/Object3DMedia.tsx new file mode 100644 index 0000000..853600a --- /dev/null +++ b/src/preview/components/AttractionWidget/media/Object3DMedia.tsx @@ -0,0 +1,52 @@ +import cn from "classnames"; +import React, { useEffect, useState } from "react"; + +import "./AttractionMedia.css"; +import ModelViewer from "../../model-viewer/ModelViewer"; +import { Icons, useLightboxContext } from "@mt/components"; +import { Object3DLightboxData } from "@mt/common-types"; + +interface Object3DMediaProps { + url: string; + watermarkUrl?: string; +} + +export const Object3DMedia = ({ url, watermarkUrl }: Object3DMediaProps) => { + // prettier-ignore + const { setData, openLightbox } = useLightboxContext(); + const [autoRotate, setAutoRotate] = useState(true); + + const handle3DFullscreenOpen = () => { + setAutoRotate(false); + setData({ + type: "OBJECT_3D", + modelUrl: url, + watermarkUrl, + }); + openLightbox(); + }; + + useEffect(() => { + setAutoRotate(true); + }, [url]); + + return ( +
+
+ + {watermarkUrl && ( + Watermark + )} +
+ + handle3DFullscreenOpen()} + /> +
+ ); +}; diff --git a/src/preview/components/AttractionWidget/media/PhotoSphereMedia.tsx b/src/preview/components/AttractionWidget/media/PhotoSphereMedia.tsx new file mode 100644 index 0000000..15c9ed6 --- /dev/null +++ b/src/preview/components/AttractionWidget/media/PhotoSphereMedia.tsx @@ -0,0 +1,62 @@ +import cn from "classnames"; +import React, { useRef } from "react"; +import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; + +import { PhotoSphereLightboxData } from "@mt/common-types"; + +import "./AttractionMedia.css"; +import { useLightboxContext } from "../../lightbox"; +import { Icons } from "@mt/components"; + +interface PhotoSphereMediaProps { + url: string; + watermarkUrl?: string; +} + +export const PhotoSphereMedia = ({ + url, + watermarkUrl, +}: PhotoSphereMediaProps) => { + // prettier-ignore + const { setData, openLightbox } = useLightboxContext(); + // react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece + const photoSphereRef = useRef(null); + + const handlePhotoSphereFullscreenOpen = () => { + photoSphereRef.current?.stopAutoRotate(); + setData({ + type: "PHOTO_SPHERE", + imageUrl: url, + watermarkUrl, + }); + openLightbox(); + }; + + return ( +
+ + {watermarkUrl && ( + Watermark + )} + {/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */} + handlePhotoSphereFullscreenOpen()} + /> +
+ ); +}; diff --git a/src/preview/components/AttractionWidget/media/VideoMedia.tsx b/src/preview/components/AttractionWidget/media/VideoMedia.tsx new file mode 100644 index 0000000..c8af0f6 --- /dev/null +++ b/src/preview/components/AttractionWidget/media/VideoMedia.tsx @@ -0,0 +1,26 @@ +import cn from "classnames"; +import React from "react"; + +import "./AttractionMedia.css"; + +interface VideoMediaProps { + url: string; + watermarkUrl?: string; +} + +export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => ( + <> +