Compare commits
4 Commits
1bb3f43979
...
d4c5db61ea
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c5db61ea | |||
| 55cdea17ea | |||
| 3725c7f569 | |||
| 2659c6a5b8 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Router } from "./router";
|
import { Router } from "./router";
|
||||||
import { CustomTheme } from "@shared";
|
import { CustomTheme, languageStore } from "@shared";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||||
import { TestingModeBanner } from "@widgets";
|
import { TestingModeBanner } from "@widgets";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
export const App: React.FC = () => (
|
export const App: React.FC = observer(() => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.documentElement.lang = languageStore.language;
|
||||||
|
}, [languageStore.language]);
|
||||||
|
|
||||||
|
return (
|
||||||
<GlobalErrorBoundary>
|
<GlobalErrorBoundary>
|
||||||
<ThemeProvider theme={CustomTheme.Light}>
|
<ThemeProvider theme={CustomTheme.Light}>
|
||||||
<TestingModeBanner />
|
<TestingModeBanner />
|
||||||
@@ -16,3 +22,4 @@ export const App: React.FC = () => (
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</GlobalErrorBoundary>
|
</GlobalErrorBoundary>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type GetRouteResponse = {
|
|||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
center_longitude: number;
|
center_longitude: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
|
button_text?: string;
|
||||||
id: number;
|
id: number;
|
||||||
path: [number, number][];
|
path: [number, number][];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ const ListOfSights = observer(() => {
|
|||||||
}, [currentSelectedSight]);
|
}, [currentSelectedSight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="right-widget">
|
<div className="right-widget" lang={selectedLanguageRight}>
|
||||||
{currentSelectedSight && (
|
{currentSelectedSight && (
|
||||||
<SightFrame
|
<SightFrame
|
||||||
key={currentSelectedSight.id}
|
key={currentSelectedSight.id}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
top: 0,
|
top: 0,
|
||||||
hasScroll: false,
|
hasScroll: false,
|
||||||
});
|
});
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
@@ -32,7 +34,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
const th = ch;
|
const th = ch;
|
||||||
|
|
||||||
if (sh <= ch) {
|
if (sh <= ch) {
|
||||||
setState({ height: th, top: 0, hasScroll: false });
|
setState((prev) => ({ ...prev, hasScroll: false }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +70,24 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
};
|
};
|
||||||
}, [update]);
|
}, [update]);
|
||||||
|
|
||||||
return state;
|
useEffect(() => {
|
||||||
|
if (state.hasScroll) {
|
||||||
|
if (hideTimerRef.current) {
|
||||||
|
clearTimeout(hideTimerRef.current);
|
||||||
|
hideTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
hideTimerRef.current = setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [state.hasScroll]);
|
||||||
|
|
||||||
|
return { ...state, visible };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||||
@@ -251,17 +270,21 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
<div ref={scrollableRef} className="scrollable">
|
<div ref={scrollableRef} className="scrollable">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{thumb.hasScroll && (
|
<div
|
||||||
<div ref={trackRef} className="custom-scrollbar-track">
|
ref={trackRef}
|
||||||
|
className="custom-scrollbar-track"
|
||||||
|
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
{thumb.visible && (
|
||||||
<div
|
<div
|
||||||
ref={thumbRef}
|
ref={thumbRef}
|
||||||
className="custom-scrollbar-thumb"
|
className="custom-scrollbar-thumb"
|
||||||
style={{ height: thumb.height, top: thumb.top }}
|
style={{ height: thumb.height, top: thumb.top }}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import "../../styles/LeftWidget.css";
|
import "../../styles/LeftWidget.css";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { apiBaseURL } from "../../api/apiConfig";
|
import { apiBaseURL } from "../../api/apiConfig";
|
||||||
|
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||||
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
|
|
||||||
const LeftWidget = observer(
|
const LeftWidget = observer(
|
||||||
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
||||||
@@ -15,8 +17,7 @@ const LeftWidget = observer(
|
|||||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||||
const [widgetHeight, setWidgetHeight] = useState(0);
|
const [widgetHeight, setWidgetHeight] = useState(0);
|
||||||
|
|
||||||
const textRef = useRef(null);
|
const layoutRef = useRef(null);
|
||||||
const activeTouch = useRef(null);
|
|
||||||
const widgetRef = useRef(null);
|
const widgetRef = useRef(null);
|
||||||
|
|
||||||
const store = useGeolocationStore();
|
const store = useGeolocationStore();
|
||||||
@@ -37,64 +38,10 @@ const LeftWidget = observer(
|
|||||||
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollContainer = textRef.current;
|
if (layoutRef.current) {
|
||||||
if (!scrollContainer) return;
|
const scrollable = layoutRef.current.querySelector(".scrollable");
|
||||||
|
if (scrollable) scrollable.scrollTop = 0;
|
||||||
const handleTouchStart = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
activeTouch.current = {
|
|
||||||
identifier: e.touches[0].identifier,
|
|
||||||
lastY: e.touches[0].clientY,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeTouch.current) {
|
|
||||||
for (const touch of e.changedTouches) {
|
|
||||||
if (touch.identifier === activeTouch.current.identifier) {
|
|
||||||
const deltaY = touch.clientY - activeTouch.current.lastY;
|
|
||||||
scrollContainer.scrollTop -= deltaY;
|
|
||||||
activeTouch.current.lastY = touch.clientY;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = (e) => {
|
|
||||||
for (const touch of e.changedTouches) {
|
|
||||||
if (
|
|
||||||
activeTouch.current &&
|
|
||||||
touch.identifier === activeTouch.current.identifier
|
|
||||||
) {
|
|
||||||
activeTouch.current = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
scrollContainer.addEventListener("touchstart", handleTouchStart, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
scrollContainer.addEventListener("touchmove", handleTouchMove, {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
scrollContainer.addEventListener("touchend", handleTouchEnd, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
|
||||||
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
|
||||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
|
||||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
|
||||||
};
|
|
||||||
}, [selectedSightData]);
|
}, [selectedSightData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,7 +159,7 @@ const LeftWidget = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
|
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>Загрузка информации...</div>
|
<div>Загрузка информации...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -238,9 +185,11 @@ const LeftWidget = observer(
|
|||||||
<div className="left-widget-address">
|
<div className="left-widget-address">
|
||||||
{selectedSightData.address}
|
{selectedSightData.address}
|
||||||
</div>
|
</div>
|
||||||
<div ref={textRef} className="left-widget-text">
|
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
|
||||||
{selectedSightData.text}
|
<div className="left-widget-text">
|
||||||
|
<ReactMarkdownComponent value={selectedSightData.text} />
|
||||||
</div>
|
</div>
|
||||||
|
</TouchableLayout>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (isVisible || selectedSightData) && !isLoading ? (
|
) : (isVisible || selectedSightData) && !isLoading ? (
|
||||||
|
|||||||
@@ -447,7 +447,9 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}}
|
}}
|
||||||
className="appeal-button"
|
className="appeal-button"
|
||||||
>
|
>
|
||||||
{selectedLanguage == "ru"
|
{route?.button_text
|
||||||
|
? route.button_text
|
||||||
|
: selectedLanguage == "ru"
|
||||||
? "Обращение губернатора"
|
? "Обращение губернатора"
|
||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? "州长致辞"
|
? "州长致辞"
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ const SightItem = ({
|
|||||||
return () => window.removeEventListener("resize", checkWidth);
|
return () => window.removeEventListener("resize", checkWidth);
|
||||||
}, [sightName]);
|
}, [sightName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localSelectedSightId !== sight.id) {
|
||||||
|
setIsExpanded(false);
|
||||||
|
}
|
||||||
|
}, [localSelectedSightId, sight.id]);
|
||||||
|
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
const newExpanded = !isExpanded;
|
const newExpanded = !isExpanded;
|
||||||
setIsExpanded(newExpanded);
|
setIsExpanded(newExpanded);
|
||||||
@@ -96,15 +102,19 @@ const SightItem = ({
|
|||||||
const stations = sightStationsCache.get(cacheKey) || [];
|
const stations = sightStationsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
|
className={
|
||||||
|
localSelectedSightId === sight.id
|
||||||
|
? "side-menu-sight-selected-wrapper"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
id={`sight-${sight.id}`}
|
id={`sight-${sight.id}`}
|
||||||
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
||||||
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
||||||
className={`side-menu-sight pointer ${
|
className="side-menu-sight pointer"
|
||||||
localSelectedSightId === sight.id ? "selected" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{sightName}
|
{sightName}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import "../../styles/AppealWidget.css";
|
import "../../styles/AppealWidget.css";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
|
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||||
|
|
||||||
function AppealWidget({
|
function AppealWidget({
|
||||||
widgetImgPath,
|
widgetImgPath,
|
||||||
@@ -38,7 +39,9 @@ function AppealWidget({
|
|||||||
ref={layoutRef}
|
ref={layoutRef}
|
||||||
className="dynamic-widget-text-scroll"
|
className="dynamic-widget-text-scroll"
|
||||||
>
|
>
|
||||||
<div className="dynamic-widget-text">{widgetText}</div>
|
<div className="dynamic-widget-text">
|
||||||
|
<ReactMarkdownComponent value={widgetText} />
|
||||||
|
</div>
|
||||||
</TouchableLayout>
|
</TouchableLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Canvas, useThree } from "@react-three/fiber";
|
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
||||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
import { OrbitControls, Center, useGLTF } from "@react-three/drei";
|
||||||
import React, { useEffect, Suspense } from "react";
|
import React, { useEffect, useRef, Suspense, useCallback } from "react";
|
||||||
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
@@ -23,6 +23,7 @@ interface ThreeViewProps {
|
|||||||
const ZOOM_FACTOR = 1.2;
|
const ZOOM_FACTOR = 1.2;
|
||||||
const MIN_DISTANCE = 1;
|
const MIN_DISTANCE = 1;
|
||||||
const MAX_DISTANCE = 100;
|
const MAX_DISTANCE = 100;
|
||||||
|
const CAMERA_FOV = 40;
|
||||||
|
|
||||||
const TouchController = () => {
|
const TouchController = () => {
|
||||||
const { camera, controls, gl } = useThree();
|
const { camera, controls, gl } = useThree();
|
||||||
@@ -197,6 +198,47 @@ const AutoResize = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FitCamera = ({
|
||||||
|
groupRef,
|
||||||
|
onReady,
|
||||||
|
}: {
|
||||||
|
groupRef: React.RefObject<THREE.Group>;
|
||||||
|
onReady: () => void;
|
||||||
|
}) => {
|
||||||
|
const { camera, controls } = useThree();
|
||||||
|
const fitted = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (fitted.current) return;
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group || group.children.length === 0) return;
|
||||||
|
|
||||||
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
|
const sphere = new THREE.Sphere();
|
||||||
|
box.getBoundingSphere(sphere);
|
||||||
|
|
||||||
|
if (sphere.radius === 0) return;
|
||||||
|
|
||||||
|
const fov = THREE.MathUtils.degToRad(CAMERA_FOV);
|
||||||
|
const dist = sphere.radius / Math.sin(fov / 2);
|
||||||
|
|
||||||
|
camera.position.set(0, 0, dist);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
if (controls) {
|
||||||
|
const orbit = controls as unknown as OrbitControlsImpl;
|
||||||
|
orbit.target.set(0, 0, 0);
|
||||||
|
orbit.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fitted.current = true;
|
||||||
|
onReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const Model = ({
|
const Model = ({
|
||||||
fileUrl,
|
fileUrl,
|
||||||
onLoad,
|
onLoad,
|
||||||
@@ -233,21 +275,37 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
|||||||
onError,
|
onError,
|
||||||
controlRef,
|
controlRef,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isReady, setIsReady] = React.useState(false);
|
||||||
|
const groupRef = useRef<THREE.Group>(null!);
|
||||||
|
|
||||||
|
const handleReady = useCallback(() => {
|
||||||
|
setIsReady(true);
|
||||||
|
onLoad?.();
|
||||||
|
}, [onLoad]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
||||||
|
{!isReady && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", inset: 0,
|
||||||
|
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
|
||||||
|
zIndex: 1,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
<Canvas
|
<Canvas
|
||||||
gl={{
|
gl={{
|
||||||
antialias: true,
|
antialias: true,
|
||||||
toneMappingExposure: 1.5,
|
toneMappingExposure: 1.5,
|
||||||
outputColorSpace: THREE.SRGBColorSpace,
|
outputColorSpace: THREE.SRGBColorSpace,
|
||||||
}}
|
}}
|
||||||
camera={{ position: [0, 0, 5], fov: 40 }}
|
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onError={(e: any) => onError?.(e.message)}
|
onError={(e: any) => onError?.(e.message)}
|
||||||
>
|
>
|
||||||
<AutoResize />
|
<AutoResize />
|
||||||
<TouchController />
|
<TouchController />
|
||||||
{controlRef && <ZoomController controlRef={controlRef} />}
|
{controlRef && <ZoomController controlRef={controlRef} />}
|
||||||
|
<FitCamera groupRef={groupRef} onReady={handleReady} />
|
||||||
<color attach="background" args={[BACKGROUND_COLOR]} />
|
<color attach="background" args={[BACKGROUND_COLOR]} />
|
||||||
<ambientLight intensity={0.8} />
|
<ambientLight intensity={0.8} />
|
||||||
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
||||||
@@ -265,23 +323,18 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
|||||||
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Stage
|
<Center precise>
|
||||||
environment={null}
|
<group ref={groupRef}>
|
||||||
intensity={1}
|
<Model fileUrl={fileUrl} />
|
||||||
castShadow={false}
|
</group>
|
||||||
shadows={false}
|
</Center>
|
||||||
adjustCamera={true}
|
|
||||||
center={{ precise: true }}
|
|
||||||
>
|
|
||||||
<Model fileUrl={fileUrl} onLoad={onLoad} />
|
|
||||||
</Stage>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
makeDefault
|
makeDefault
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
target={[50, 50, 50]}
|
target={[0, 0, 0]}
|
||||||
minDistance={1}
|
minDistance={1}
|
||||||
maxDistance={100}
|
maxDistance={100}
|
||||||
enableDamping={true}
|
enableDamping={true}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
.side-menu-sights-block {
|
.side-menu-sights-block {
|
||||||
height: calc(60%);
|
height: calc(60%);
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
margin-left: 20px;
|
margin-left: 0;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-right: 5px;
|
padding-right: 5px;
|
||||||
touch-action: none; /* Отключаем стандартные действия */
|
touch-action: none; /* Отключаем стандартные действия */
|
||||||
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,63 @@
|
|||||||
|
|
||||||
|
|
||||||
.dynamic-widget-text {
|
.dynamic-widget-text {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: 400;
|
font-weight: 300;
|
||||||
line-height: 190%;
|
line-height: 135%;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 135%;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 135%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container h1,
|
||||||
|
.dynamic-widget-text .react-markdown-container h2,
|
||||||
|
.dynamic-widget-text .react-markdown-container h3,
|
||||||
|
.dynamic-widget-text .react-markdown-container h4,
|
||||||
|
.dynamic-widget-text .react-markdown-container h5,
|
||||||
|
.dynamic-widget-text .react-markdown-container h6 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container ul,
|
||||||
|
.dynamic-widget-text .react-markdown-container ol {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container blockquote {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-widget-text .react-markdown-container a {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,17 +67,78 @@
|
|||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-widget-text {
|
.left-widget-text-scroll.scrollable-container {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text-scroll .scrollable-viewport {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 135%;
|
line-height: 135%;
|
||||||
max-height: 200px; /* Пример ограничения высоты */
|
padding-right: 3px;
|
||||||
overflow-y: auto;
|
}
|
||||||
touch-action: none;
|
|
||||||
overscroll-behavior: contain;
|
.left-widget-text .react-markdown-container {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 135%;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 135%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container h1,
|
||||||
|
.left-widget-text .react-markdown-container h2,
|
||||||
|
.left-widget-text .react-markdown-container h3,
|
||||||
|
.left-widget-text .react-markdown-container h4,
|
||||||
|
.left-widget-text .react-markdown-container h5,
|
||||||
|
.left-widget-text .react-markdown-container h6 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container ul,
|
||||||
|
.left-widget-text .react-markdown-container ol {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container blockquote {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-widget-text .react-markdown-container a {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-widget-image {
|
.left-widget-image {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
@keyframes pulse-chevron {
|
@keyframes pulse-chevron {
|
||||||
0% { transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); }
|
0% {
|
||||||
40% { transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12); }
|
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
||||||
60% { transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14); }
|
}
|
||||||
100% { transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); }
|
40% {
|
||||||
|
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-svg {
|
.chevron-svg {
|
||||||
@@ -39,7 +47,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-right, #806C59);
|
var(--carrier-right, #806c59);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
max-height: 68px;
|
max-height: 68px;
|
||||||
@@ -87,7 +95,7 @@
|
|||||||
|
|
||||||
background-color: color-mix(
|
background-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--carrier-right, #806C59) 80%,
|
var(--carrier-right, #806c59) 80%,
|
||||||
black
|
black
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,7 +228,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-right, #806C59);
|
var(--carrier-right, #806c59);
|
||||||
max-height: calc(100vh - 128px);
|
max-height: calc(100vh - 128px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +348,7 @@
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent 35%,
|
transparent 35%,
|
||||||
color-mix(in srgb, var(--carrier-right, #806C59) 80%, black) 50%,
|
color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
|
||||||
transparent 65%
|
transparent 65%
|
||||||
);
|
);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -380,17 +388,26 @@
|
|||||||
|
|
||||||
.sight-frame-menu-fade.left {
|
.sight-frame-menu-fade.left {
|
||||||
left: 0;
|
left: 0;
|
||||||
background: linear-gradient(to right, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent);
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
border-radius: 0 0 0 10px;
|
border-radius: 0 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-fade.right {
|
.sight-frame-menu-fade.right {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(to left, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent);
|
background: linear-gradient(
|
||||||
|
to left,
|
||||||
|
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
border-radius: 0 0 10px 0;
|
border-radius: 0 0 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu {
|
.sight-frame-menu {
|
||||||
|
z-index: 10000;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 7px 60px;
|
padding: 7px 60px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -433,6 +450,7 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
transition:
|
transition:
|
||||||
background-color 0.1s ease,
|
background-color 0.1s ease,
|
||||||
color 0.1s ease;
|
color 0.1s ease;
|
||||||
@@ -440,7 +458,7 @@
|
|||||||
|
|
||||||
.sight-frame-menu-point.active {
|
.sight-frame-menu-point.active {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 2px solid #fff;
|
border-bottom-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
||||||
@@ -594,7 +612,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alphabet {
|
.alphabet {
|
||||||
width: 100px;
|
width: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -654,8 +673,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alphabet-position {
|
.alphabet-position {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transfer-button-container {
|
.transfer-button-container {
|
||||||
@@ -822,7 +842,7 @@
|
|||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
background: var(--carrier-right, #806C59);
|
background: var(--carrier-right, #806c59);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-top: 120px;
|
margin-top: 120px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
width: 220px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-buttons {
|
.side-menu-buttons {
|
||||||
@@ -227,12 +229,10 @@
|
|||||||
.side-menu-sights-block {
|
.side-menu-sights-block {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin-left: 20px;
|
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
width: auto;
|
width: 100%;
|
||||||
max-width: calc(100% - 20px);
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -240,7 +240,6 @@
|
|||||||
|
|
||||||
.side-menu-sight {
|
.side-menu-sight {
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
margin-right: 20px;
|
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border-bottom: 1px solid
|
border-bottom: 1px solid
|
||||||
@@ -254,6 +253,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-sight-selected-wrapper {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
margin-left: -20px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-sight > span {
|
.side-menu-sight > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb {
|
.custom-scrollbar-thumb {
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
|
|
||||||
.side-menu-sights-block .scrollable {
|
.side-menu-sights-block .scrollable {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .scrollable-viewport {
|
.list-of-sights-content .scrollable-viewport {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const ArticleListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const CarrierListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
countryStore,
|
countryStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
|
snapshotStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
@@ -60,7 +61,13 @@ export const CityCreatePage = observer(() => {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const ruCityName = createCityData.ru.name.trim();
|
||||||
await cityStore.createCity();
|
await cityStore.createCity();
|
||||||
|
try {
|
||||||
|
await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to create empty snapshot for city:", e);
|
||||||
|
}
|
||||||
toast.success("Город успешно создан");
|
toast.success("Город успешно создан");
|
||||||
navigate("/city");
|
navigate("/city");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export const CityListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const CountryListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const MediaListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const [routeCoords, setRouteCoords] = useState("");
|
const [routeCoords, setRouteCoords] = useState("");
|
||||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||||
|
const [buttonText, setButtonText] = useState("");
|
||||||
const [direction, setDirection] = useState("backward");
|
const [direction, setDirection] = useState("backward");
|
||||||
const [scaleMin, setScaleMin] = useState("10");
|
const [scaleMin, setScaleMin] = useState("10");
|
||||||
const [scaleMax, setScaleMax] = useState("100");
|
const [scaleMax, setScaleMax] = useState("100");
|
||||||
@@ -292,6 +293,10 @@ export const RouteCreatePage = observer(() => {
|
|||||||
newRoute.governor_appeal = governor_appeal;
|
newRoute.governor_appeal = governor_appeal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (buttonText.trim()) {
|
||||||
|
newRoute.button_text = buttonText.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const newId = await routeStore.createRoute(newRoute);
|
const newId = await routeStore.createRoute(newRoute);
|
||||||
toast.success("Маршрут успешно создан");
|
toast.success("Маршрут успешно создан");
|
||||||
navigate(`/route/${newId}/edit`);
|
navigate(`/route/${newId}/edit`);
|
||||||
@@ -407,6 +412,18 @@ export const RouteCreatePage = observer(() => {
|
|||||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Текст кнопки обращения
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={buttonText}
|
||||||
|
onChange={(e) => setButtonText(e.target.value)}
|
||||||
|
placeholder="Обращение губернатора"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
helperText="Если пусто, будет использован текст по умолчанию"
|
||||||
|
/>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
Обращение к пассажирам
|
Обращение к пассажирам
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -566,6 +566,22 @@ export const RouteEditPage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Текст кнопки обращения
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={editRouteData.button_text || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
button_text: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Обращение губернатора"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
helperText="Если пусто, будет использован текст по умолчанию"
|
||||||
|
/>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
Обращение к пассажирам
|
Обращение к пассажирам
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export const RouteListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface RouteData {
|
|||||||
icon_size?: number;
|
icon_size?: number;
|
||||||
font_size: number;
|
font_size: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
|
button_text?: string;
|
||||||
id: number;
|
id: number;
|
||||||
path: [number, number][];
|
path: [number, number][];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export const SightListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,24 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { snapshotStore, authStore, routeStore, selectedCityStore } from "@shared";
|
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { runInAction } from "mobx";
|
import { runInAction } from "mobx";
|
||||||
|
|
||||||
|
function escapeRegex(s: string) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportNameRegex(cityNames: string[]): RegExp {
|
||||||
|
if (!cityNames.length) return /.+/;
|
||||||
|
const pattern = cityNames.map(escapeRegex).join("|");
|
||||||
|
return new RegExp(`^(${pattern})_.+$`);
|
||||||
|
}
|
||||||
|
|
||||||
export const SnapshotCreatePage = observer(() => {
|
export const SnapshotCreatePage = observer(() => {
|
||||||
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -24,10 +34,22 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
|
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
|
||||||
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
|
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const exportNameRegex = useMemo(() => {
|
||||||
|
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
||||||
|
return buildExportNameRegex(names);
|
||||||
|
}, [cityStore.cities["ru"].data.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cityStore.cities["ru"].loaded) {
|
||||||
|
cityStore.getCities("ru");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const canReadRoutes = authStore.canRead("routes");
|
const canReadRoutes = authStore.canRead("routes");
|
||||||
|
|
||||||
const startExport = async () => {
|
const startExport = async () => {
|
||||||
@@ -75,7 +97,7 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
const routes = routeStore.routes.data;
|
const routes = routeStore.routes.data;
|
||||||
const numberCount = new Map<string, number>();
|
const numberCount = new Map<string, number>();
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
const num = (route.route_number ?? "").trim();
|
const num = (route.route_sys_number ?? "").trim();
|
||||||
if (num) {
|
if (num) {
|
||||||
numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
|
numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
@@ -115,7 +137,19 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
label="Название"
|
label="Название"
|
||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
error={!!nameError}
|
||||||
|
helperText={nameError ?? " "}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setName(val);
|
||||||
|
const trimmed = val.trim();
|
||||||
|
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
||||||
|
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
||||||
|
setNameError("Название должно начинаться с названия существующего города");
|
||||||
|
} else {
|
||||||
|
setNameError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -124,7 +158,7 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading || !name.trim()}
|
disabled={isLoading || !exportNameRegex.test(name.trim())}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -152,13 +186,13 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
|
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<p className="mb-3">
|
<p className="mb-3">
|
||||||
Обнаружены маршруты с одинаковыми номерами. Это может привести к
|
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
|
||||||
некорректным данным в экспорте.
|
некорректным данным в экспорте.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
{duplicateRouteNumbers.map((num) => (
|
{duplicateRouteNumbers.map((num) => (
|
||||||
<li key={num}>
|
<li key={num}>
|
||||||
Найдены повторяющиеся маршруты под номером №{num}
|
Найдены повторяющиеся маршруты с номером трассы №{num}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
|
import { authStore, languageStore, snapshotStore, cityStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||||
@@ -9,6 +9,16 @@ import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogCont
|
|||||||
|
|
||||||
const LOW_STORAGE_THRESHOLD_GB = 10;
|
const LOW_STORAGE_THRESHOLD_GB = 10;
|
||||||
|
|
||||||
|
function escapeRegex(s: string) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportNameRegex(cityNames: string[]): RegExp {
|
||||||
|
if (!cityNames.length) return /.+/;
|
||||||
|
const pattern = cityNames.map(escapeRegex).join("|");
|
||||||
|
return new RegExp(`^(${pattern})_.+$`);
|
||||||
|
}
|
||||||
|
|
||||||
const SEGMENT_COLORS = [
|
const SEGMENT_COLORS = [
|
||||||
"#FF3B30",
|
"#FF3B30",
|
||||||
"#FF9500",
|
"#FF9500",
|
||||||
@@ -45,6 +55,12 @@ export const SnapshotListPage = observer(() => {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
||||||
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
||||||
|
const [emptySnapshotNameError, setEmptySnapshotNameError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const exportNameRegex = useMemo(() => {
|
||||||
|
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
||||||
|
return buildExportNameRegex(names);
|
||||||
|
}, [cityStore.cities["ru"].data.length]);
|
||||||
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
||||||
const [paginationModel, setPaginationModel] = useState({
|
const [paginationModel, setPaginationModel] = useState({
|
||||||
page: 0,
|
page: 0,
|
||||||
@@ -201,6 +217,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
disabled={isLowStorage}
|
disabled={isLowStorage}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEmptySnapshotName("");
|
setEmptySnapshotName("");
|
||||||
|
setEmptySnapshotNameError(null);
|
||||||
setIsEmptySnapshotModalOpen(true);
|
setIsEmptySnapshotModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -299,7 +316,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -353,7 +370,19 @@ export const SnapshotListPage = observer(() => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
label="Название"
|
label="Название"
|
||||||
value={emptySnapshotName}
|
value={emptySnapshotName}
|
||||||
onChange={(e) => setEmptySnapshotName(e.target.value)}
|
error={!!emptySnapshotNameError}
|
||||||
|
helperText={emptySnapshotNameError ?? " "}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setEmptySnapshotName(val);
|
||||||
|
const trimmed = val.trim();
|
||||||
|
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
||||||
|
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
||||||
|
setEmptySnapshotNameError("Название должно начинаться с названия существующего города");
|
||||||
|
} else {
|
||||||
|
setEmptySnapshotNameError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -363,7 +392,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
|
disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setIsCreatingEmpty(true);
|
setIsCreatingEmpty(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const StationListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export const UserListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const VehicleListPage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{(rows.length > 0 || searchQuery) && (
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type Route = {
|
|||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
center_longitude: number;
|
center_longitude: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
|
button_text?: string;
|
||||||
id: number;
|
id: number;
|
||||||
icon: string;
|
icon: string;
|
||||||
path: number[][];
|
path: number[][];
|
||||||
@@ -143,6 +144,7 @@ class RouteStore {
|
|||||||
center_latitude: "",
|
center_latitude: "",
|
||||||
center_longitude: "",
|
center_longitude: "",
|
||||||
governor_appeal: 0,
|
governor_appeal: 0,
|
||||||
|
button_text: "" as string | undefined,
|
||||||
id: 0,
|
id: 0,
|
||||||
icon: "",
|
icon: "",
|
||||||
path: [] as number[][],
|
path: [] as number[][],
|
||||||
|
|||||||
Reference in New Issue
Block a user