Compare commits

4 Commits

33 changed files with 453 additions and 156 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "white-nights",
"private": true,
"version": "1.0.7",
"version": "1.0.8",
"type": "module",
"license": "UNLICENSED",
"scripts": {

View File

@@ -1,13 +1,19 @@
import * as React from "react";
import { Router } from "./router";
import { CustomTheme } from "@shared";
import { CustomTheme, languageStore } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
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>
<ThemeProvider theme={CustomTheme.Light}>
<TestingModeBanner />
@@ -16,3 +22,4 @@ export const App: React.FC = () => (
</ThemeProvider>
</GlobalErrorBoundary>
);
});

View File

@@ -55,6 +55,7 @@ export type GetRouteResponse = {
center_latitude: number;
center_longitude: number;
governor_appeal: number;
button_text?: string;
id: number;
path: [number, number][];
rotate: number;

View File

@@ -411,7 +411,7 @@ const ListOfSights = observer(() => {
}, [currentSelectedSight]);
return (
<div className="right-widget">
<div className="right-widget" lang={selectedLanguageRight}>
{currentSelectedSight && (
<SightFrame
key={currentSelectedSight.id}

View File

@@ -20,6 +20,8 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
top: 0,
hasScroll: false,
});
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafRef = useRef<number | null>(null);
const update = useCallback(() => {
@@ -32,7 +34,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const th = ch;
if (sh <= ch) {
setState({ height: th, top: 0, hasScroll: false });
setState((prev) => ({ ...prev, hasScroll: false }));
return;
}
@@ -68,7 +70,24 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
};
}, [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>(
@@ -251,17 +270,21 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
<div ref={scrollableRef} className="scrollable">
{children}
</div>
{thumb.hasScroll && (
<div ref={trackRef} className="custom-scrollbar-track">
<div
ref={trackRef}
className="custom-scrollbar-track"
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
>
{thumb.visible && (
<div
ref={thumbRef}
className="custom-scrollbar-thumb"
style={{ height: thumb.height, top: thumb.top }}
/>
</div>
)}
</div>
</div>
</div>
);
},
);

View File

@@ -5,6 +5,8 @@ import { useGeolocationStore } from "../../stores";
import "../../styles/LeftWidget.css";
import { apiStore } from "../../api/ApiStore/store";
import { apiBaseURL } from "../../api/apiConfig";
import { ReactMarkdownComponent } from "../ReactMarkdown";
import { TouchableLayout } from "../TouchableLayout";
const LeftWidget = observer(
({ selectedSightId, onClose, isVisible, sightTop }) => {
@@ -15,8 +17,7 @@ const LeftWidget = observer(
const [isImageLoaded, setIsImageLoaded] = useState(false);
const [widgetHeight, setWidgetHeight] = useState(0);
const textRef = useRef(null);
const activeTouch = useRef(null);
const layoutRef = useRef(null);
const widgetRef = useRef(null);
const store = useGeolocationStore();
@@ -37,64 +38,10 @@ const LeftWidget = observer(
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
useEffect(() => {
const scrollContainer = textRef.current;
if (!scrollContainer) return;
const handleTouchStart = (e) => {
e.stopPropagation();
if (e.touches.length === 1) {
activeTouch.current = {
identifier: e.touches[0].identifier,
lastY: e.touches[0].clientY,
};
if (layoutRef.current) {
const scrollable = layoutRef.current.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
}
};
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]);
useEffect(() => {
@@ -212,7 +159,7 @@ const LeftWidget = observer(
};
return (
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
{isLoading ? (
<div>Загрузка информации...</div>
) : error ? (
@@ -238,9 +185,11 @@ const LeftWidget = observer(
<div className="left-widget-address">
{selectedSightData.address}
</div>
<div ref={textRef} className="left-widget-text">
{selectedSightData.text}
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
<div className="left-widget-text">
<ReactMarkdownComponent value={selectedSightData.text} />
</div>
</TouchableLayout>
</div>
</>
) : (isVisible || selectedSightData) && !isLoading ? (

View File

@@ -447,7 +447,9 @@ const SideMenu = observer(({ onMenuToggle }) => {
}}
className="appeal-button"
>
{selectedLanguage == "ru"
{route?.button_text
? route.button_text
: selectedLanguage == "ru"
? "Обращение губернатора"
: selectedLanguage == "zh"
? "州长致辞"

View File

@@ -84,6 +84,12 @@ const SightItem = ({
return () => window.removeEventListener("resize", checkWidth);
}, [sightName]);
useEffect(() => {
if (localSelectedSightId !== sight.id) {
setIsExpanded(false);
}
}, [localSelectedSightId, sight.id]);
const handleClick = (e) => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
@@ -96,15 +102,19 @@ const SightItem = ({
const stations = sightStationsCache.get(cacheKey) || [];
return (
<div>
<div
className={
localSelectedSightId === sight.id
? "side-menu-sight-selected-wrapper"
: ""
}
>
<div
ref={containerRef}
id={`sight-${sight.id}`}
onPointerDown={(e) => handlePointerDown(e, sight.id)}
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
className={`side-menu-sight pointer ${
localSelectedSightId === sight.id ? "selected" : ""
}`}
className="side-menu-sight pointer"
>
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName}

View File

@@ -1,6 +1,7 @@
import { useRef, useEffect } from "react";
import "../../styles/AppealWidget.css";
import { TouchableLayout } from "../TouchableLayout";
import { ReactMarkdownComponent } from "../ReactMarkdown";
function AppealWidget({
widgetImgPath,
@@ -38,7 +39,9 @@ function AppealWidget({
ref={layoutRef}
className="dynamic-widget-text-scroll"
>
<div className="dynamic-widget-text">{widgetText}</div>
<div className="dynamic-widget-text">
<ReactMarkdownComponent value={widgetText} />
</div>
</TouchableLayout>
</div>
);

View File

@@ -1,6 +1,6 @@
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import React, { useEffect, Suspense } from "react";
import { Canvas, useThree, useFrame } from "@react-three/fiber";
import { OrbitControls, Center, useGLTF } from "@react-three/drei";
import React, { useEffect, useRef, Suspense, useCallback } from "react";
import { BACKGROUND_COLOR } from "../../assets/Constants";
import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
@@ -23,6 +23,7 @@ interface ThreeViewProps {
const ZOOM_FACTOR = 1.2;
const MIN_DISTANCE = 1;
const MAX_DISTANCE = 100;
const CAMERA_FOV = 40;
const TouchController = () => {
const { camera, controls, gl } = useThree();
@@ -197,6 +198,47 @@ const AutoResize = () => {
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 = ({
fileUrl,
onLoad,
@@ -233,21 +275,37 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
onError,
controlRef,
}) => {
const [isReady, setIsReady] = React.useState(false);
const groupRef = useRef<THREE.Group>(null!);
const handleReady = useCallback(() => {
setIsReady(true);
onLoad?.();
}, [onLoad]);
return (
<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
gl={{
antialias: true,
toneMappingExposure: 1.5,
outputColorSpace: THREE.SRGBColorSpace,
}}
camera={{ position: [0, 0, 5], fov: 40 }}
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
style={{ width: "100%", height: "100%" }}
onError={(e: any) => onError?.(e.message)}
>
<AutoResize />
<TouchController />
{controlRef && <ZoomController controlRef={controlRef} />}
<FitCamera groupRef={groupRef} onReady={handleReady} />
<color attach="background" args={[BACKGROUND_COLOR]} />
<ambientLight intensity={0.8} />
<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} />
<Suspense fallback={null}>
<Stage
environment={null}
intensity={1}
castShadow={false}
shadows={false}
adjustCamera={true}
center={{ precise: true }}
>
<Model fileUrl={fileUrl} onLoad={onLoad} />
</Stage>
<Center precise>
<group ref={groupRef}>
<Model fileUrl={fileUrl} />
</group>
</Center>
</Suspense>
<OrbitControls
makeDefault
enableZoom={true}
enablePan={true}
target={[50, 50, 50]}
target={[0, 0, 0]}
minDistance={1}
maxDistance={100}
enableDamping={true}

View File

@@ -14,9 +14,9 @@
.side-menu-sights-block {
height: calc(60%);
overflow-y: scroll;
margin-left: 20px;
margin-left: 0;
margin-top: 8px;
margin-right: 5px;
padding-right: 5px;
touch-action: none; /* Отключаем стандартные действия */
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
}

View File

@@ -66,8 +66,63 @@
.dynamic-widget-text {
font-size: 14px;
font-weight: 400;
line-height: 190%;
font-size: 16px;
font-weight: 300;
line-height: 135%;
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%;
}
.left-widget-text {
.left-widget-text-scroll.scrollable-container {
margin-top: 15px;
overflow: hidden;
width: 100%;
}
.left-widget-text-scroll .scrollable-viewport {
max-height: 200px;
}
.left-widget-text {
color: #fff;
font-family: "Roboto";
font-size: 16px;
font-weight: 300;
line-height: 135%;
max-height: 200px; /* Пример ограничения высоты */
overflow-y: auto;
touch-action: none;
overscroll-behavior: contain;
padding-right: 3px;
}
.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 {

View File

@@ -1,8 +1,16 @@
@keyframes pulse-chevron {
0% { 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); }
0% {
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 {
@@ -39,7 +47,7 @@
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
var(--carrier-right, #806C59);
var(--carrier-right, #806c59);
color: white;
max-height: 68px;
@@ -87,7 +95,7 @@
background-color: color-mix(
in srgb,
var(--carrier-right, #806C59) 80%,
var(--carrier-right, #806c59) 80%,
black
);
}
@@ -220,7 +228,7 @@
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
var(--carrier-right, #806C59);
var(--carrier-right, #806c59);
max-height: calc(100vh - 128px);
}
@@ -340,7 +348,7 @@
background: linear-gradient(
to right,
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%
);
border-radius: 3px;
@@ -380,17 +388,26 @@
.sight-frame-menu-fade.left {
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;
}
.sight-frame-menu-fade.right {
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;
}
.sight-frame-menu {
z-index: 10000;
position: relative;
padding: 7px 60px;
width: 100%;
@@ -433,6 +450,7 @@
padding: 8px 12px;
white-space: nowrap;
flex-shrink: 0;
border-bottom: 2px solid transparent;
transition:
background-color 0.1s ease,
color 0.1s ease;
@@ -440,7 +458,7 @@
.sight-frame-menu-point.active {
font-weight: 600;
border-bottom: 2px solid #fff;
border-bottom-color: #fff;
}
.sight-frame-text-wrapper::-webkit-scrollbar-track {
@@ -594,7 +612,8 @@
}
.alphabet {
width: 100px;
width: 40px;
flex-shrink: 0;
margin-right: 10px;
padding-top: 24px;
display: flex;
@@ -654,8 +673,9 @@
}
.alphabet-position {
display: inline-flex;
display: flex;
justify-content: space-between;
width: 100%;
}
.transfer-button-container {
@@ -822,7 +842,7 @@
border-radius: 32px;
right: 20px;
bottom: 20px;
background: var(--carrier-right, #806C59);
background: var(--carrier-right, #806c59);
z-index: 9999;
display: flex;
}

View File

@@ -35,6 +35,8 @@
font-size: 16px;
margin-top: 120px;
font-weight: 500;
width: 220px;
text-align: center;
}
.side-menu-buttons {
@@ -227,12 +229,10 @@
.side-menu-sights-block {
flex: 1;
min-height: 0;
margin-left: 20px;
margin-top: 8px;
touch-action: none;
overscroll-behavior: contain;
width: auto;
max-width: calc(100% - 20px);
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
@@ -240,7 +240,6 @@
.side-menu-sight {
padding-bottom: 2px;
margin-right: 20px;
margin-bottom: 6px;
margin-top: 6px;
border-bottom: 1px solid
@@ -254,6 +253,12 @@
position: relative;
}
.side-menu-sight-selected-wrapper {
background: rgba(0, 0, 0, 0.2);
margin-left: -20px;
padding-left: 20px;
}
.side-menu-sight > span {
display: inline-block;
white-space: nowrap;

View File

@@ -43,6 +43,7 @@
position: relative;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: opacity 0.2s ease;
}
.custom-scrollbar-thumb {
@@ -67,6 +68,7 @@
.side-menu-sights-block .scrollable {
height: 100%;
padding-left: 20px;
}
.list-of-sights-content .scrollable-viewport {

View File

@@ -108,7 +108,7 @@ export const ArticleListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -162,7 +162,7 @@ export const CarrierListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -17,6 +17,7 @@ import {
countryStore,
languageStore,
mediaStore,
snapshotStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
@@ -60,7 +61,13 @@ export const CityCreatePage = observer(() => {
const handleCreate = async () => {
try {
setIsLoading(true);
const ruCityName = createCityData.ru.name.trim();
await cityStore.createCity();
try {
await snapshotStore.createEmptySnapshot(`${ruCityName}устой_Экспорт`);
} catch (e) {
console.warn("Failed to create empty snapshot for city:", e);
}
toast.success("Город успешно создан");
navigate("/city");
} catch (error) {

View File

@@ -159,7 +159,7 @@ export const CityListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -111,7 +111,7 @@ export const CountryListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -113,7 +113,7 @@ export const MediaListPage = observer(() => {
return (
<>
<div className="w-full">
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -50,6 +50,7 @@ export const RouteCreatePage = observer(() => {
const [routeCoords, setRouteCoords] = useState("");
const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [buttonText, setButtonText] = useState("");
const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState("100");
@@ -292,6 +293,10 @@ export const RouteCreatePage = observer(() => {
newRoute.governor_appeal = governor_appeal;
}
if (buttonText.trim()) {
newRoute.button_text = buttonText.trim();
}
const newId = await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан");
navigate(`/route/${newId}/edit`);
@@ -407,6 +412,18 @@ export const RouteCreatePage = observer(() => {
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>

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>

View File

@@ -270,7 +270,7 @@ export const RouteListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -6,6 +6,7 @@ export interface RouteData {
icon_size?: number;
font_size: number;
governor_appeal: number;
button_text?: string;
id: number;
path: [number, number][];
rotate: number;

View File

@@ -181,7 +181,7 @@ export const SightListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -6,14 +6,24 @@ import {
DialogContent,
DialogActions,
} 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 { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
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(() => {
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
const navigate = useNavigate();
@@ -24,10 +34,22 @@ export const SnapshotCreatePage = observer(() => {
}, []);
const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
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 startExport = async () => {
@@ -75,7 +97,7 @@ export const SnapshotCreatePage = observer(() => {
const routes = routeStore.routes.data;
const numberCount = new Map<string, number>();
for (const route of routes) {
const num = (route.route_number ?? "").trim();
const num = (route.route_sys_number ?? "").trim();
if (num) {
numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
}
@@ -115,7 +137,19 @@ export const SnapshotCreatePage = observer(() => {
label="Название"
required
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
@@ -124,7 +158,7 @@ export const SnapshotCreatePage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading || !name.trim()}
disabled={isLoading || !exportNameRegex.test(name.trim())}
>
{isLoading ? (
<div className="flex items-center gap-2">
@@ -152,13 +186,13 @@ export const SnapshotCreatePage = observer(() => {
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
<DialogContent>
<p className="mb-3">
Обнаружены маршруты с одинаковыми номерами. Это может привести к
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
некорректным данным в экспорте.
</p>
<ul className="list-disc pl-5">
{duplicateRouteNumbers.map((num) => (
<li key={num}>
Найдены повторяющиеся маршруты под номером {num}
Найдены повторяющиеся маршруты с номером трассы {num}
</li>
))}
</ul>

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
@@ -9,6 +9,16 @@ import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogCont
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 = [
"#FF3B30",
"#FF9500",
@@ -45,6 +55,12 @@ export const SnapshotListPage = observer(() => {
const [searchQuery, setSearchQuery] = useState("");
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
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 [paginationModel, setPaginationModel] = useState({
page: 0,
@@ -201,6 +217,7 @@ export const SnapshotListPage = observer(() => {
disabled={isLowStorage}
onClick={() => {
setEmptySnapshotName("");
setEmptySnapshotNameError(null);
setIsEmptySnapshotModalOpen(true);
}}
>
@@ -299,7 +316,7 @@ export const SnapshotListPage = observer(() => {
</Alert>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
@@ -353,7 +370,19 @@ export const SnapshotListPage = observer(() => {
fullWidth
label="Название"
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"
/>
</DialogContent>
@@ -363,7 +392,7 @@ export const SnapshotListPage = observer(() => {
</Button>
<Button
variant="contained"
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty}
onClick={async () => {
setIsCreatingEmpty(true);
try {

View File

@@ -225,7 +225,7 @@ export const StationListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -147,7 +147,7 @@ export const UserListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -173,7 +173,7 @@ export const VehicleListPage = observer(() => {
/>
</div>
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -13,6 +13,7 @@ export type Route = {
center_latitude: number;
center_longitude: number;
governor_appeal: number;
button_text?: string;
id: number;
icon: string;
path: number[][];
@@ -143,6 +144,7 @@ class RouteStore {
center_latitude: "",
center_longitude: "",
governor_appeal: 0,
button_text: "" as string | undefined,
id: 0,
icon: "",
path: [] as number[][],