Compare commits

4 Commits

33 changed files with 453 additions and 156 deletions

View File

@@ -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": {

View File

@@ -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>
); );
});

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
); );
}, },
); );

View File

@@ -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 ? (

View File

@@ -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"
? "州长致辞" ? "州长致辞"

View File

@@ -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}

View File

@@ -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>
); );

View File

@@ -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}

View File

@@ -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; /* Предотвращаем прокрутку родительских элементов */
} }

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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} />
)} )}

View File

@@ -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} />
)} )}

View File

@@ -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) {

View File

@@ -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} />
)} )}

View File

@@ -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} />
)} )}

View File

@@ -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} />
)} )}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />
)} )}

View File

@@ -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;

View File

@@ -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} />
)} )}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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} />
)} )}

View File

@@ -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} />
)} )}

View File

@@ -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} />
)} )}

View File

@@ -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[][],