Compare commits
4 Commits
d67df0c2e1
...
94f512e0e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f512e0e4 | |||
| 60c6840db4 | |||
| 248eea6f85 | |||
| 7f8b90c15e |
4
src/client/src/App.d.ts
vendored
Normal file
4
src/client/src/App.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import React from "react";
|
||||
|
||||
declare const App: React.FC;
|
||||
export default App;
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
GetCityResponse,
|
||||
GetSightArticleResponse,
|
||||
} from "./types";
|
||||
// @ts-ignore
|
||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||
import { resamplePath } from "../../utils/animationUtils";
|
||||
|
||||
class ApiStore {
|
||||
isLoading = true;
|
||||
@@ -54,7 +56,7 @@ class ApiStore {
|
||||
carrier: GetCarrierResponse | null = null;
|
||||
city: GetCityResponse | null = null;
|
||||
|
||||
private positionIndex = 0;
|
||||
positionIndex = 0;
|
||||
private positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
simulationSpeed = 1;
|
||||
@@ -87,7 +89,26 @@ class ApiStore {
|
||||
};
|
||||
|
||||
getRoute = async () => {
|
||||
this.route = await getRoute(this.routeId!);
|
||||
const route = await getRoute(this.routeId!);
|
||||
if (route.path && route.path.length > 1) {
|
||||
// Рассчитываем общую дистанцию для выбора адекватного шага ресемплинга
|
||||
let totalDist = 0;
|
||||
for (let i = 0; i < route.path.length - 1; i++) {
|
||||
const p1 = route.path[i];
|
||||
const p2 = route.path[i + 1];
|
||||
totalDist += Math.sqrt(
|
||||
Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)
|
||||
);
|
||||
}
|
||||
// Хотим иметь примерно 2000 точек для равномерности и плавности
|
||||
const segmentLength = totalDist / 2000;
|
||||
if (segmentLength > 0) {
|
||||
route.path = resamplePath(route.path as [number, number][], segmentLength);
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
this.route = route;
|
||||
});
|
||||
this.updateOrderedRouteStations();
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type GetContextResponse = {
|
||||
};
|
||||
endStopId: string;
|
||||
nearestSightId: string;
|
||||
nearestStationId?: string | null;
|
||||
rawCoordinates: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -105,6 +106,7 @@ export type GetRouteSightsResponse = {
|
||||
icon?: string;
|
||||
alt_icon?: string;
|
||||
is_default_icon?: boolean;
|
||||
short_name?: string;
|
||||
}[];
|
||||
|
||||
export type GetRouteStationsResponse = {
|
||||
|
||||
9
src/client/src/api/apiConfig.d.ts
vendored
Normal file
9
src/client/src/api/apiConfig.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
|
||||
export declare const apiBaseURL: string;
|
||||
export declare const geoBaseURL: string;
|
||||
export declare const weatherBaseURL: string;
|
||||
export declare const getMediaUrl: (id: string) => string;
|
||||
export declare const apiInstance: AxiosInstance;
|
||||
export declare const geoInstance: AxiosInstance;
|
||||
export declare const weatherInstance: AxiosInstance;
|
||||
8
src/client/src/assets/Constants.d.ts
vendored
Normal file
8
src/client/src/assets/Constants.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export declare const UP_SCALE: number;
|
||||
export declare const PATH_WIDTH: number;
|
||||
export declare const STATION_RADIUS: number;
|
||||
export declare const STATION_OUTLINE_WIDTH: number;
|
||||
export declare const SIGHT_SIZE: number;
|
||||
export declare const SCALE_FACTOR: number;
|
||||
export declare const BACKGROUND_COLOR: number;
|
||||
export declare const PATH_COLOR: number;
|
||||
@@ -428,6 +428,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const processedSightName = useMemo(() => {
|
||||
if (!sight_name) return sight_name;
|
||||
|
||||
// Handle \n line breaks (только в правом виджете)
|
||||
if (sight_name.includes("\n")) {
|
||||
return sight_name.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
const namePattern =
|
||||
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
||||
|
||||
|
||||
13
src/client/src/components/OverlayScrollbarsWrapper.d.ts
vendored
Normal file
13
src/client/src/components/OverlayScrollbarsWrapper.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
export declare const OverlayScrollbarsWrapper: React.ForwardRefExoticComponent<
|
||||
React.PropsWithChildren<{
|
||||
className?: string;
|
||||
onScroll?: (event: Event) => void;
|
||||
overflowX?: string;
|
||||
overflowY?: string;
|
||||
scrollbarVisibility?: string;
|
||||
[key: string]: any;
|
||||
}> &
|
||||
React.RefAttributes<HTMLElement>
|
||||
>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import "./ReactMarkdown.css";
|
||||
|
||||
@@ -6,22 +6,30 @@ export const SimulationSettings = observer(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 10010 }}>
|
||||
<div style={{ position: "fixed", top: 12, right: 12, zIndex: 2147483646 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 6,
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)",
|
||||
color: "white", cursor: "pointer", fontSize: 18,
|
||||
color: "white", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
⚙
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.69.07-1.08s-.03-.73-.07-1.08l2.33-1.82c.21-.16.27-.46.13-.7l-2.21-3.83a.55.55 0 0 0-.68-.22l-2.75 1.1a8.1 8.1 0 0 0-1.86-1.08l-.42-2.93A.545.545 0 0 0 14 2h-4c-.27 0-.5.2-.54.46l-.42 2.93c-.68.28-1.3.65-1.86 1.08L4.43 5.37a.543.543 0 0 0-.68.22L1.54 9.42c-.14.24-.08.54.13.7l2.33 1.82c-.04.35-.07.7-.07 1.08s.03.73.07 1.08L1.67 15.92c-.21.16-.27.46-.13.7l2.21 3.83c.14.24.43.31.68.22l2.75-1.1c.56.43 1.18.8 1.86 1.08l.42 2.93c.04.26.27.46.54.46h4c.27 0 .5-.2.54-.46l.42-2.93c.68-.28 1.3-.65 1.86-1.08l2.75 1.1c.25.09.54.02.68-.22l2.21-3.83c.14-.24.08-.54-.13-.7l-2.33-1.82Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
marginTop: 6, background: "rgba(20,20,20,0.9)",
|
||||
position: "absolute", top: 42, right: 0,
|
||||
background: "rgba(20,20,20,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
|
||||
padding: "10px 12px", minWidth: 200, fontSize: 13,
|
||||
}}>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
|
||||
import { ReactNode, useEffect, useState, useRef, useCallback } from "react";
|
||||
import { ReactNode, useEffect, useState, useRef } from "react";
|
||||
import { useTransform } from "./transformContext";
|
||||
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { useApplication } from "@pixi/react";
|
||||
import { useGeolocation } from "../../context/GeolocationContext";
|
||||
import ContentAPI from "../../api/content/content.api";
|
||||
import React from "react";
|
||||
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
|
||||
import { useCameraAnimationStore } from "../../stores";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import debounce from "lodash/debounce";
|
||||
@@ -25,7 +21,6 @@ export const InfiniteCanvas = observer(
|
||||
setIsAutoMode,
|
||||
userActivityTimestamp,
|
||||
updateUserActivity,
|
||||
autoModeStartTimestamp,
|
||||
setAutoModeStartTimestamp,
|
||||
} = useTransform();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -49,17 +44,14 @@ export const InfiniteCanvas = observer(
|
||||
position: { x: number; y: number };
|
||||
} | null>(null);
|
||||
// Keep these for backward compatibility, but we'll use pinchStartData for calculations
|
||||
const [initialPinchDistance, setInitialPinchDistance] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [initialPinchMidpoint, setInitialPinchMidpoint] = useState<{
|
||||
const [, setInitialPinchDistance] = useState<number | null>(null);
|
||||
const [, setInitialPinchMidpoint] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
|
||||
const [scaleMax, setScaleMax] = useState(3); // Default max scale
|
||||
const store = useGeolocationStore();
|
||||
const cameraAnimationStore = useCameraAnimationStore();
|
||||
|
||||
// Add debounced version of syncState to reduce jittering
|
||||
@@ -277,6 +269,7 @@ export const InfiniteCanvas = observer(
|
||||
setPosition(newPosition);
|
||||
syncStateDebounced(newPosition, scale);
|
||||
}
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import React, {
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import { extend } from "@pixi/react";
|
||||
import { Container, Graphics, Sprite, Text } from "pixi.js";
|
||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||
import { TransformProvider, useTransform } from "./transformContext";
|
||||
import { InfiniteCanvas } from "./InfiniteCanvas";
|
||||
import { TravelPath } from "./TravelPath";
|
||||
import { Station } from "./Station";
|
||||
import { SightsLayer } from "./Sight";
|
||||
// @ts-ignore
|
||||
import Loader from "../Loader";
|
||||
import {
|
||||
BACKGROUND_COLOR,
|
||||
BUS_COLOR,
|
||||
STATION_OUTLINE_WIDTH,
|
||||
STATION_RADIUS,
|
||||
UP_SCALE,
|
||||
} from "./Constants";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import "../../styles/MapLayer.css";
|
||||
import { useGeolocationStore, useCameraAnimationStore } from "../../stores";
|
||||
import { useCameraAnimationStore } from "../../stores";
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { TramIcon } from "./TramIcon";
|
||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import WebGLMap from "./WebGLMap";
|
||||
@@ -42,7 +31,7 @@ export function Map() {
|
||||
);
|
||||
}
|
||||
|
||||
function rotatePoint(x, y, originX, originY, angle) {
|
||||
function rotatePoint(x: number, y: number, originX: number, originY: number, angle: number) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const dx = x - originX;
|
||||
@@ -53,8 +42,6 @@ function rotatePoint(x, y, originX, originY, angle) {
|
||||
}
|
||||
|
||||
const RouteMap = observer(() => {
|
||||
const store = useGeolocationStore();
|
||||
const { contextData } = store;
|
||||
const {
|
||||
routeData,
|
||||
stationData,
|
||||
@@ -77,7 +64,6 @@ const RouteMap = observer(() => {
|
||||
scale,
|
||||
} = useTransform();
|
||||
const cameraAnimationStore = useCameraAnimationStore();
|
||||
const parentRef = useRef(null);
|
||||
|
||||
const [rotationAngle, setRotationAngle] = useState(0);
|
||||
|
||||
@@ -143,7 +129,7 @@ const RouteMap = observer(() => {
|
||||
const rotationOriginY = 0;
|
||||
|
||||
const transformGeoToMapLocal = useCallback(
|
||||
(latitude, longitude) => {
|
||||
(latitude: number, longitude: number) => {
|
||||
if (centerLat === undefined || centerLon === undefined) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
@@ -239,99 +225,6 @@ const RouteMap = observer(() => {
|
||||
transformedStations,
|
||||
]);
|
||||
|
||||
const drawActualBusPos = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
if (transformedCurrentCoordinates) {
|
||||
g.circle(
|
||||
transformedCurrentCoordinates.x,
|
||||
transformedCurrentCoordinates.y,
|
||||
STATION_RADIUS / scale < 10
|
||||
? 10
|
||||
: STATION_RADIUS / scale > 20
|
||||
? 20
|
||||
: STATION_RADIUS / scale
|
||||
);
|
||||
g.fill({ color: BUS_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||
}
|
||||
},
|
||||
[transformedCurrentCoordinates, scale]
|
||||
);
|
||||
|
||||
const scaledPoints = useMemo(() => {
|
||||
if (!routeData?.path) return [];
|
||||
|
||||
return routeData.path.map(([latitude, longitude]) => {
|
||||
const { x, y } = transformGeoToMapLocal(latitude, longitude);
|
||||
return rotatePoint(x, y, rotationOriginX, rotationOriginY, rotationAngle);
|
||||
});
|
||||
}, [
|
||||
routeData?.path,
|
||||
transformGeoToMapLocal,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
const transformedStationsEn = useMemo(() => {
|
||||
if (!stationDataEn) return [];
|
||||
|
||||
return stationDataEn.map((station) => {
|
||||
const { x, y } = transformGeoToMapLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
const rotatedCoords = rotatePoint(
|
||||
x,
|
||||
y,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle
|
||||
);
|
||||
return {
|
||||
...station,
|
||||
longitude: rotatedCoords.x,
|
||||
latitude: rotatedCoords.y,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
stationDataEn,
|
||||
transformGeoToMapLocal,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
const transformedStationsZh = useMemo(() => {
|
||||
if (!stationDataZh) return [];
|
||||
|
||||
return stationDataZh.map((station) => {
|
||||
const { x, y } = transformGeoToMapLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
const rotatedCoords = rotatePoint(
|
||||
x,
|
||||
y,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle
|
||||
);
|
||||
return {
|
||||
...station,
|
||||
longitude: rotatedCoords.x,
|
||||
latitude: rotatedCoords.y,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
stationDataZh,
|
||||
transformGeoToMapLocal,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
if (
|
||||
!routeData ||
|
||||
!stationData ||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// SightsLayer.tsx
|
||||
import React from "react";
|
||||
import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { useTransform } from "./transformContext";
|
||||
@@ -9,7 +8,6 @@ import { useGeolocationStore } from "../../stores"; // Импортируем us
|
||||
|
||||
const BASE_ICON_SIZE = 30;
|
||||
const CLUSTER_RADIUS_BASE = 10;
|
||||
const CLUSTER_COLOR = 0x1a73e8;
|
||||
|
||||
type Cluster = {
|
||||
id: string;
|
||||
@@ -150,7 +148,7 @@ function SingleSight({
|
||||
readonly sight: SightData;
|
||||
onSightClick: (sightId: string) => void;
|
||||
}) {
|
||||
const { scale } = useTransform();
|
||||
useTransform();
|
||||
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||
const store = useGeolocationStore();
|
||||
const { setIsGovernorWidgetOpen } = store;
|
||||
@@ -197,7 +195,7 @@ function SightCluster({
|
||||
}) {
|
||||
const store = useGeolocationStore();
|
||||
const { setIsGovernorWidgetOpen } = store;
|
||||
const { scale } = useTransform();
|
||||
useTransform();
|
||||
const radius = CLUSTER_RADIUS_BASE;
|
||||
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||
const fontSize = 14;
|
||||
@@ -334,7 +332,7 @@ export function SightsLayer({
|
||||
sights,
|
||||
pathPoints,
|
||||
}: Readonly<SightsLayerProps>) {
|
||||
const { scale } = useTransform();
|
||||
useTransform();
|
||||
const distanceThreshold = BASE_ICON_SIZE * 3;
|
||||
|
||||
const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Texture, Assets, Graphics } from "pixi.js";
|
||||
import { Texture, Assets } from "pixi.js";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useTransform } from "./transformContext";
|
||||
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||
@@ -11,8 +10,6 @@ const basePath = new URL(
|
||||
const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
|
||||
.href;
|
||||
|
||||
// Константы анимации (как в HTML файле)
|
||||
const ANIMATION_DURATION = 1200; // 1.2 секунды
|
||||
const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
|
||||
|
||||
// Функция для проверки расстояния до ближайшей точки маршрута
|
||||
@@ -101,7 +98,7 @@ const getDistanceToStations = (
|
||||
offset_x?: number;
|
||||
offset_y?: number;
|
||||
}[],
|
||||
debug: boolean = false
|
||||
_debug: boolean = false
|
||||
) => {
|
||||
if (!stations || stations.length === 0) {
|
||||
return Infinity;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||
import { lerpAngle } from "../../utils/animationUtils";
|
||||
import tramBase from "../../assets/tramPosition/Tram Base.svg";
|
||||
import tramSvg from "../../assets/tramPosition/Tram_Second.svg";
|
||||
import { getMediaUrl } from "../../api/apiConfig";
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
BUS_COLOR,
|
||||
BASE_ICON_SIZE,
|
||||
CLUSTER_RADIUS_BASE,
|
||||
CLUSTER_COLOR,
|
||||
ACTIVE_STATION_COLOR,
|
||||
} from "./Constants";
|
||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
@@ -40,7 +38,7 @@ const YELLOW_ICON_FILTER =
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const debugWebglLog = (...args: unknown[]) => {
|
||||
const debugWebglLog = (..._args: unknown[]) => {
|
||||
if (!DEBUG_WEBGL_ROUTE_MAP) return;
|
||||
};
|
||||
|
||||
@@ -424,7 +422,7 @@ export const WebGLMap = observer(() => {
|
||||
}, []);
|
||||
|
||||
const clampPosition = useCallback(
|
||||
(pos: { x: number; y: number }, currentScale: number) => {
|
||||
(pos: { x: number; y: number }, _currentScale: number) => {
|
||||
return pos;
|
||||
},
|
||||
[],
|
||||
@@ -799,7 +797,9 @@ export const WebGLMap = observer(() => {
|
||||
const textBlockPositionX = rx + labelOffsetX;
|
||||
const textBlockPositionY = ry + labelOffsetY;
|
||||
|
||||
const approximateTextWidth = st.name.length * fontSize * 0.6;
|
||||
const nameLines = st.name.replace(/\\n/g, '\n').split('\n');
|
||||
const longestLine = nameLines.reduce((a: string, b: string) => a.length > b.length ? a : b, '');
|
||||
const approximateTextWidth = longestLine.length * fontSize * 0.6;
|
||||
const textWidthInMapCoords = approximateTextWidth / scale;
|
||||
|
||||
let anchorXOffset = 0;
|
||||
@@ -829,8 +829,8 @@ export const WebGLMap = observer(() => {
|
||||
result.push({
|
||||
x: sx,
|
||||
y: sy,
|
||||
name: st.name,
|
||||
sub,
|
||||
name: st.name.replace(/\\n/g, '\n'),
|
||||
sub: sub ? sub.replace(/\\n/g, '\n') : sub,
|
||||
anchorX: anchorX,
|
||||
anchorY: anchorY,
|
||||
distance: distanceInPixels,
|
||||
@@ -880,6 +880,32 @@ export const WebGLMap = observer(() => {
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
||||
const orderedStationSegs = useMemo(() => {
|
||||
if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[];
|
||||
return (orderedRouteStations as any[]).map((ordStation) => {
|
||||
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(ordStation.id));
|
||||
if (stIdx < 0) return -1;
|
||||
const sx = stationPoints[stIdx * 2];
|
||||
const sy = stationPoints[stIdx * 2 + 1];
|
||||
if (sx === undefined || sy === undefined) return -1;
|
||||
let best = -1, bestD = Infinity;
|
||||
for (let i = 0; i < routePath.length - 2; i += 2) {
|
||||
const p1x = routePath[i], p1y = routePath[i + 1];
|
||||
const p2x = routePath[i + 2], p2y = routePath[i + 3];
|
||||
const dx = p2x - p1x, dy = p2y - p1y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
||||
const cl = Math.max(0, Math.min(1, t));
|
||||
const px = p1x + cl * dx, py = p1y + cl * dy;
|
||||
const d = Math.hypot(sx - px, sy - py);
|
||||
if (d < bestD) { bestD = d; best = i / 2; }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}, [orderedRouteStations, stationData, stationPoints, routePath]);
|
||||
|
||||
const sightPoints = useMemo(() => {
|
||||
if (!sightData || !routeData) return new Float32Array();
|
||||
const centerLat = routeData.center_latitude;
|
||||
@@ -1097,6 +1123,8 @@ export const WebGLMap = observer(() => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const prevPositionIndexRef = useRef<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const centerLat = routeData?.center_latitude;
|
||||
const centerLon = routeData?.center_longitude;
|
||||
@@ -1114,7 +1142,14 @@ export const WebGLMap = observer(() => {
|
||||
const rx = x * cos - y * sin;
|
||||
const ry = x * sin + y * cos;
|
||||
|
||||
if (apiStore.simulationInstantMove) {
|
||||
const curIdx = apiStore.positionIndex;
|
||||
const prevIdx = prevPositionIndexRef.current;
|
||||
const pathLen = apiStore.route?.path?.length ?? 0;
|
||||
const isWrap = prevIdx >= 0 && pathLen > 0 &&
|
||||
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
||||
prevPositionIndexRef.current = curIdx;
|
||||
|
||||
if (apiStore.simulationInstantMove || isWrap) {
|
||||
setYellowDotImmediate(rx, ry);
|
||||
} else {
|
||||
animateYellowDotTo(rx, ry);
|
||||
@@ -1165,8 +1200,8 @@ export const WebGLMap = observer(() => {
|
||||
gl.enableVertexAttribArray(attribs.a_pos);
|
||||
gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const vcount = routePath.length / 2;
|
||||
let tramSegIndex = getCurrentSegIndex();
|
||||
const simulationDirection = apiStore.simulationDirection;
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const desiredRouteWidthCss = 7;
|
||||
@@ -1264,23 +1299,69 @@ export const WebGLMap = observer(() => {
|
||||
const r1 = ((PATH_COLOR >> 16) & 0xff) / 255;
|
||||
const g1 = ((PATH_COLOR >> 8) & 0xff) / 255;
|
||||
const b1 = (PATH_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
|
||||
if (tramSegIndex >= 0) {
|
||||
const animatedPos = animatedYellowDotPosition;
|
||||
if (
|
||||
tramSegIndex >= 0 &&
|
||||
animatedPos &&
|
||||
animatedPos.x !== undefined &&
|
||||
animatedPos.y !== undefined
|
||||
) {
|
||||
if (simulationDirection === 1) {
|
||||
// Вперёд: закрашено от начала до трамвая
|
||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||
const passedPoints: number[] = [];
|
||||
|
||||
for (let i = 0; i <= tramSegIndex; i++) {
|
||||
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
|
||||
passedPoints.push(animatedPos.x, animatedPos.y);
|
||||
|
||||
if (passedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(passedPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
const unpassedPoints: number[] = [];
|
||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
if (unpassedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(unpassedPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
} else {
|
||||
// Назад: закрашено от трамвая до конца
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
const unpassedPoints: number[] = [];
|
||||
for (let i = 0; i <= tramSegIndex; i++) {
|
||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||
if (unpassedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(unpassedPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||
const passedPoints: number[] = [];
|
||||
passedPoints.push(animatedPos.x, animatedPos.y);
|
||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
if (passedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(passedPoints),
|
||||
@@ -1290,30 +1371,16 @@ export const WebGLMap = observer(() => {
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
} else {
|
||||
// Позиция трамвая неизвестна — рисуем весь маршрут серым
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
|
||||
const animatedPos = animatedYellowDotPosition;
|
||||
if (
|
||||
animatedPos &&
|
||||
animatedPos.x !== undefined &&
|
||||
animatedPos.y !== undefined
|
||||
) {
|
||||
const unpassedPoints: number[] = [];
|
||||
|
||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||
|
||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
const allPoints: number[] = [];
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
allPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
|
||||
if (unpassedPoints.length >= 4) {
|
||||
if (allPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(unpassedPoints),
|
||||
new Float32Array(allPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
@@ -1348,92 +1415,32 @@ export const WebGLMap = observer(() => {
|
||||
|
||||
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
||||
|
||||
let currentStationIndexInOrdered = -1;
|
||||
if (currentStationId && orderedRouteStations) {
|
||||
currentStationIndexInOrdered = orderedRouteStations.findIndex(
|
||||
(station: any) => String(station.id) === String(currentStationId),
|
||||
);
|
||||
if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
|
||||
const passedPts1: number[] = [];
|
||||
const unpassedPts1: number[] = [];
|
||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||
const orderedStation = (orderedRouteStations as any[])[i];
|
||||
const stationSeg = orderedStationSegs[i] ?? -1;
|
||||
if (!orderedStation || stationSeg < 0) continue;
|
||||
const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex;
|
||||
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id));
|
||||
if (stIdx < 0) continue;
|
||||
const sx = stationPoints[stIdx * 2] as number;
|
||||
const sy = stationPoints[stIdx * 2 + 1] as number;
|
||||
if (isPassed) { passedPts1.push(sx, sy); } else { unpassedPts1.push(sx, sy); }
|
||||
}
|
||||
|
||||
if (
|
||||
currentStationIndexInOrdered >= 0 &&
|
||||
orderedRouteStations &&
|
||||
stationData
|
||||
) {
|
||||
const passedStations: number[] = [];
|
||||
|
||||
for (let i = 0; i < currentStationIndexInOrdered; i++) {
|
||||
const orderedStation = orderedRouteStations[i];
|
||||
if (orderedStation) {
|
||||
const stationIndexInData = stationData.findIndex(
|
||||
(station: any) =>
|
||||
String(station.id) === String(orderedStation.id),
|
||||
);
|
||||
if (stationIndexInData >= 0) {
|
||||
passedStations.push(
|
||||
stationPoints[stationIndexInData * 2] as number,
|
||||
stationPoints[stationIndexInData * 2 + 1] as number,
|
||||
);
|
||||
if (passedPts1.length > 0) {
|
||||
gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (passedStations.length > 0) {
|
||||
const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255;
|
||||
const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255;
|
||||
const b_passed = (PATH_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array(passedStations),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
gl.drawArrays(gl.POINTS, 0, passedStations.length / 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentStationIndexInOrdered >= 0 &&
|
||||
orderedRouteStations &&
|
||||
stationData
|
||||
) {
|
||||
const unpassedStations: number[] = [];
|
||||
|
||||
for (
|
||||
let i = currentStationIndexInOrdered + 1;
|
||||
i < orderedRouteStations.length;
|
||||
i++
|
||||
) {
|
||||
const orderedStation = orderedRouteStations[i];
|
||||
if (orderedStation) {
|
||||
const stationIndexInData = stationData.findIndex(
|
||||
(station: any) =>
|
||||
String(station.id) === String(orderedStation.id),
|
||||
);
|
||||
if (stationIndexInData >= 0) {
|
||||
unpassedStations.push(
|
||||
stationPoints[stationIndexInData * 2] as number,
|
||||
stationPoints[stationIndexInData * 2 + 1] as number,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unpassedStations.length > 0) {
|
||||
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array(unpassedStations),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2);
|
||||
if (unpassedPts1.length > 0) {
|
||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
||||
}
|
||||
} else {
|
||||
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1);
|
||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
||||
}
|
||||
@@ -1452,53 +1459,6 @@ export const WebGLMap = observer(() => {
|
||||
|
||||
const toPointsArray = (arr: number[]) => new Float32Array(arr);
|
||||
|
||||
const pathPts: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < routePath.length; i += 2)
|
||||
pathPts.push({ x: routePath[i], y: routePath[i + 1] });
|
||||
const getSeg = (px: number, py: number) => {
|
||||
if (pathPts.length < 2) return -1;
|
||||
let best = -1,
|
||||
bestD = Infinity;
|
||||
for (let i = 0; i < pathPts.length - 1; i++) {
|
||||
const p1 = pathPts[i],
|
||||
p2 = pathPts[i + 1];
|
||||
const dx = p2.x - p1.x,
|
||||
dy = p2.y - p1.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
const t = ((px - p1.x) * dx + (py - p1.y) * dy) / len2;
|
||||
const tt = Math.max(0, Math.min(1, t));
|
||||
const cx = p1.x + tt * dx,
|
||||
cy = p1.y + tt * dy;
|
||||
const d = Math.hypot(px - cx, py - cy);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
let tramSegForStations = -1;
|
||||
{
|
||||
const cLat = routeData?.center_latitude,
|
||||
cLon = routeData?.center_longitude;
|
||||
const tram = apiStore?.context?.currentCoordinates as any;
|
||||
if (tram && cLat !== undefined && cLon !== undefined) {
|
||||
const loc = coordinatesToLocal(
|
||||
tram.latitude - cLat,
|
||||
tram.longitude - cLon,
|
||||
);
|
||||
const wx = loc.x * UP_SCALE,
|
||||
wy = loc.y * UP_SCALE;
|
||||
const cosR = Math.cos(rotationAngle),
|
||||
sinR = Math.sin(rotationAngle);
|
||||
const tx = wx * cosR - wy * sinR,
|
||||
ty = wx * sinR + wy * cosR;
|
||||
tramSegForStations = getSeg(tx, ty);
|
||||
}
|
||||
}
|
||||
|
||||
let activeStationIndex = -1;
|
||||
const tramCoords = apiStore?.context?.currentCoordinates;
|
||||
if (
|
||||
@@ -1551,37 +1511,21 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
}
|
||||
|
||||
let currentStationIndexInOrdered = -1;
|
||||
if (currentStationId && orderedRouteStations) {
|
||||
currentStationIndexInOrdered = orderedRouteStations.findIndex(
|
||||
(station: any) => String(station.id) === String(currentStationId),
|
||||
);
|
||||
}
|
||||
|
||||
const passedStationIds = new Set<string>();
|
||||
const unpassedStationIds = new Set<string>();
|
||||
|
||||
if (currentStationIndexInOrdered >= 0 && orderedRouteStations) {
|
||||
for (let i = 0; i < currentStationIndexInOrdered; i++) {
|
||||
const station = orderedRouteStations[i];
|
||||
if (station) {
|
||||
passedStationIds.add(String(station.id));
|
||||
}
|
||||
}
|
||||
|
||||
for (
|
||||
let i = currentStationIndexInOrdered;
|
||||
i < orderedRouteStations.length;
|
||||
i++
|
||||
) {
|
||||
const station = orderedRouteStations[i];
|
||||
if (station) {
|
||||
unpassedStationIds.add(String(station.id));
|
||||
}
|
||||
if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
|
||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||
const station = (orderedRouteStations as any[])[i];
|
||||
const seg = orderedStationSegs[i] ?? -1;
|
||||
if (!station || seg < 0) continue;
|
||||
const isPassed = simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
||||
if (isPassed) passedStationIds.add(String(station.id));
|
||||
else unpassedStationIds.add(String(station.id));
|
||||
}
|
||||
} else {
|
||||
if (orderedRouteStations) {
|
||||
orderedRouteStations.forEach((station: any) => {
|
||||
(orderedRouteStations as any[]).forEach((station) => {
|
||||
unpassedStationIds.add(String(station.id));
|
||||
});
|
||||
}
|
||||
@@ -1718,12 +1662,12 @@ export const WebGLMap = observer(() => {
|
||||
const cos = Math.cos(rotationAngle);
|
||||
const sin = Math.sin(rotationAngle);
|
||||
|
||||
const startStationData = stationData.find(
|
||||
(station) => station.id.toString() === apiStore.context?.startStopId,
|
||||
);
|
||||
const endStationData = stationData.find(
|
||||
(station) => station.id.toString() === apiStore.context?.endStopId,
|
||||
);
|
||||
const startStationData = orderedRouteStations?.[0]
|
||||
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id))
|
||||
: stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId);
|
||||
const endStationData = orderedRouteStations?.length
|
||||
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id))
|
||||
: stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId);
|
||||
|
||||
const terminalStations: number[] = [];
|
||||
|
||||
@@ -1823,7 +1767,7 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex;
|
||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||
})()
|
||||
: false;
|
||||
|
||||
@@ -1856,7 +1800,7 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex;
|
||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||
})()
|
||||
: false;
|
||||
|
||||
@@ -1882,20 +1826,11 @@ export const WebGLMap = observer(() => {
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
|
||||
if (startStationData && endStationData) {
|
||||
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0);
|
||||
const startIsPassed = simulationDirection === 1 ? true : isStartPassed;
|
||||
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
||||
gl.uniform4f(u_color_pts, startIsPassed ? r_passed : r_unpassed, startIsPassed ? g_passed : g_unpassed, startIsPassed ? b_passed : b_unpassed, 1.0);
|
||||
gl.drawArrays(gl.POINTS, 0, 1);
|
||||
|
||||
if (isEndPassed) {
|
||||
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0);
|
||||
} else {
|
||||
gl.uniform4f(
|
||||
u_color_pts,
|
||||
r_unpassed,
|
||||
g_unpassed,
|
||||
b_unpassed,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0);
|
||||
gl.drawArrays(gl.POINTS, 1, 1);
|
||||
} else {
|
||||
const isStartStation = startStationData !== undefined;
|
||||
@@ -1935,6 +1870,8 @@ export const WebGLMap = observer(() => {
|
||||
nearestStationId,
|
||||
currentStationId,
|
||||
orderedRouteStations,
|
||||
orderedStationSegs,
|
||||
apiStore.simulationDirection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2331,11 +2268,10 @@ export const WebGLMap = observer(() => {
|
||||
? { right: 0, transform: "none" }
|
||||
: { left: "50%", transform: "translateX(-50%)" };
|
||||
|
||||
const apiBaseUrl = apiBaseURL;
|
||||
const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon);
|
||||
const iconSrc = isMediaIdEmptyResult
|
||||
? null
|
||||
: `${apiBaseUrl}/media/${station?.icon}/download`;
|
||||
: buildMediaDownloadUrl(mediaBaseUrl, station!.icon!, mediaToken);
|
||||
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
||||
|
||||
return (
|
||||
@@ -2379,7 +2315,7 @@ export const WebGLMap = observer(() => {
|
||||
fontSize: primaryFontSize,
|
||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
whiteSpace: "pre-line",
|
||||
}}
|
||||
>
|
||||
{l.name}
|
||||
@@ -2395,7 +2331,7 @@ export const WebGLMap = observer(() => {
|
||||
lineHeight: secondaryLineHeight,
|
||||
color: "#CBCBCB",
|
||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||
whiteSpace: "nowrap",
|
||||
whiteSpace: "pre-line",
|
||||
...secondaryPositionStyle,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
|
||||
@@ -4,9 +4,7 @@ import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
|
||||
const TransformContext = createContext<{
|
||||
position: { x: number; y: number };
|
||||
|
||||
@@ -231,7 +231,6 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
height = "100%",
|
||||
onLoad,
|
||||
onError,
|
||||
onAspectRatioCalculated,
|
||||
controlRef,
|
||||
}) => {
|
||||
return (
|
||||
@@ -244,7 +243,7 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
}}
|
||||
camera={{ position: [0, 0, 5], fov: 40 }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onError={(e) => onError?.(e.message)}
|
||||
onError={(e: any) => onError?.(e.message)}
|
||||
>
|
||||
<AutoResize />
|
||||
<TouchController />
|
||||
@@ -269,7 +268,7 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
<Stage
|
||||
environment={null}
|
||||
intensity={1}
|
||||
contactShadow={false}
|
||||
castShadow={false}
|
||||
shadows={false}
|
||||
adjustCamera={true}
|
||||
center={{ precise: true }}
|
||||
|
||||
@@ -52,33 +52,6 @@ class CameraAnimationStore {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
private calculateDistance(p1: CameraPosition, p2: CameraPosition): number {
|
||||
return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
|
||||
}
|
||||
|
||||
private isNearStation(
|
||||
tramPos: CameraPosition,
|
||||
stations: Station[]
|
||||
): { isNear: boolean; distance: number } {
|
||||
if (!stations || stations.length === 0)
|
||||
return { isNear: false, distance: Infinity };
|
||||
const threshold = 300; // Порог в координатах карты
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const station of stations) {
|
||||
const distance = this.calculateDistance(tramPos, {
|
||||
x: station.longitude,
|
||||
y: station.latitude,
|
||||
});
|
||||
minDistance = Math.min(minDistance, distance);
|
||||
}
|
||||
|
||||
return {
|
||||
isNear: minDistance < threshold,
|
||||
distance: minDistance,
|
||||
};
|
||||
}
|
||||
|
||||
public setUpdateCallback(
|
||||
callback: ((pos: CameraPosition, zoom: number) => void) | null
|
||||
) {
|
||||
@@ -140,7 +113,7 @@ class CameraAnimationStore {
|
||||
public followTram(
|
||||
tramMapPos: CameraPosition,
|
||||
screenCenter: CameraPosition,
|
||||
stations: Station[] = []
|
||||
_stations: Station[] = []
|
||||
) {
|
||||
// Анимация начинается с текущего зума и плавно переходит к максимальному зуму
|
||||
// для плавного приближения к желтой точке при слежении
|
||||
|
||||
@@ -215,6 +215,8 @@
|
||||
transition:
|
||||
transform 0.3s ease-out,
|
||||
opacity 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side-menu-sights.slide-in {
|
||||
@@ -227,7 +229,8 @@
|
||||
}
|
||||
|
||||
.side-menu-sights-block {
|
||||
height: calc(100% - 20px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
touch-action: none;
|
||||
@@ -236,6 +239,7 @@
|
||||
max-width: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.side-menu-sight {
|
||||
|
||||
@@ -134,6 +134,58 @@ export class PositionAnimator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Передискретизация пути для обеспечения равномерного расстояния между точками
|
||||
* @param path - массив [lat, lon] или [x, y]
|
||||
* @param segmentLength - желаемое расстояние между точками (в единицах координат)
|
||||
* @returns новый массив точек
|
||||
*/
|
||||
export const resamplePath = <T extends number[]>(path: T[], segmentLength: number): T[] => {
|
||||
if (path.length < 2) return path;
|
||||
|
||||
const newPath: T[] = [path[0]];
|
||||
let leftover = 0;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const p1 = path[i];
|
||||
const p2 = path[i + 1];
|
||||
const dx = p2[0] - p1[0];
|
||||
const dy = p2[1] - p1[1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist === 0) continue;
|
||||
|
||||
let currentDist = segmentLength - leftover;
|
||||
while (currentDist <= dist) {
|
||||
const t = currentDist / dist;
|
||||
const point = new Array(p1.length) as T;
|
||||
for (let j = 0; j < p1.length; j++) {
|
||||
point[j] = p1[j] + (p2[j] - p1[j]) * t;
|
||||
}
|
||||
newPath.push(point);
|
||||
currentDist += segmentLength;
|
||||
}
|
||||
leftover = dist - (currentDist - segmentLength);
|
||||
}
|
||||
|
||||
// Добавляем последнюю точку, если она существенно отличается от последней добавленной
|
||||
const lastP = path[path.length - 1];
|
||||
const lastNewP = newPath[newPath.length - 1];
|
||||
let isDifferent = false;
|
||||
for (let j = 0; j < lastP.length; j++) {
|
||||
if (Math.abs(lastP[j] - lastNewP[j]) > 0.0000001) {
|
||||
isDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDifferent) {
|
||||
newPath.push(lastP);
|
||||
}
|
||||
|
||||
return newPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Класс для анимации по полярным координатам
|
||||
* Основано на логике анимации из HTML файла (a.html)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, articlesStore, languageStore, SearchInput } from "@shared";
|
||||
import { authStore, articlesStore, languageStore, SearchInput, selectedCityStore } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Eye, Minus } from "lucide-react";
|
||||
@@ -75,14 +75,16 @@ export const ArticleListPage = observer(() => {
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
return articleList[language].data
|
||||
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
|
||||
.filter((article) => !cityId || article.city_id === cityId)
|
||||
.map((article) => ({
|
||||
id: article.id,
|
||||
heading: article.heading,
|
||||
body: article.body,
|
||||
}));
|
||||
}, [articleList[language].data, searchQuery]);
|
||||
}, [articleList[language].data, searchQuery, selectedCityStore.selectedCityId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -113,6 +115,7 @@ export const ArticleListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteArticles}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -168,6 +168,7 @@ export const CarrierListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteCarriers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -28,7 +28,8 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
const { createCityData, setCreateCityData } = cityStore;
|
||||
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
||||
cityStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
@@ -72,7 +73,7 @@ export const CityCreatePage = observer(() => {
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,7 +112,7 @@ export const CityCreatePage = observer(() => {
|
||||
e.target.value,
|
||||
createCityData.country_code,
|
||||
createCityData.arms,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -127,7 +128,7 @@ export const CityCreatePage = observer(() => {
|
||||
createCityData[language].name,
|
||||
e.target.value,
|
||||
createCityData.arms,
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -139,6 +140,14 @@ export const CityCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код города для погоды"
|
||||
type="number"
|
||||
value={createCityData.weather_city_code ?? 0}
|
||||
onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
@@ -153,7 +162,7 @@ export const CityCreatePage = observer(() => {
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
|
||||
@@ -40,7 +40,13 @@ export const CityEditPage = observer(() => {
|
||||
>(null);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
||||
const {
|
||||
editCityData,
|
||||
editCity,
|
||||
getCity,
|
||||
setEditCityData,
|
||||
setEditCityWeatherCode,
|
||||
} = cityStore;
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia, getOneMedia } = mediaStore;
|
||||
|
||||
@@ -74,6 +80,7 @@ export const CityEditPage = observer(() => {
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
setEditCityWeatherCode(ruData.weather_city_code ?? 0);
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
|
||||
@@ -107,7 +114,7 @@ export const CityEditPage = observer(() => {
|
||||
: null;
|
||||
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
|
||||
? null
|
||||
: selectedMedia?.id ?? editCityData.arms;
|
||||
: (selectedMedia?.id ?? editCityData.arms);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
@@ -179,6 +186,14 @@ export const CityEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код города для погоды"
|
||||
type="number"
|
||||
value={editCityData.weather_city_code ?? 0}
|
||||
onChange={(e) => setEditCityWeatherCode(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
|
||||
@@ -162,6 +162,7 @@ export const CityListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteCities}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const CountryListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteCountries}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const CreateSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { getCities } = cityStore;
|
||||
const { getArticles } = articlesStore;
|
||||
const { needLeaveAgree } = createSightStore;
|
||||
const needLeave = createSightStore.needLeaveAgree;
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
@@ -36,9 +36,15 @@ export const CreateSightPage = observer(() => {
|
||||
|
||||
let blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||
needLeave && currentLocation.pathname !== nextLocation.pathname,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked" && !needLeave) {
|
||||
blocker.proceed();
|
||||
}
|
||||
}, [blocker.state, needLeave]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!authStore.me) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -23,7 +23,7 @@ export const MediaCreatePage = observer(() => {
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await mediaStore.createMedia(name, type);
|
||||
await mediaStore.createMedia(name, type, selectedCityStore.selectedCityId);
|
||||
toast.success("Медиа успешно создано");
|
||||
navigate("/media");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared";
|
||||
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput, selectedCityStore } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Trash2, Minus } from "lucide-react";
|
||||
@@ -98,14 +98,16 @@ export const MediaListPage = observer(() => {
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
return media
|
||||
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
|
||||
.filter((item) => !cityId || item.city_id === cityId)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
media_name: item.media_name,
|
||||
media_type: item.media_type,
|
||||
}));
|
||||
}, [media, searchQuery]);
|
||||
}, [media, searchQuery, selectedCityStore.selectedCityId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,6 +131,7 @@ export const MediaListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteMedia}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { apiStore } from "../../../client/src/api/ApiStore/store";
|
||||
import App from "../../../client/src/App";
|
||||
|
||||
@@ -286,9 +286,9 @@ export const RouteCreatePage = observer(() => {
|
||||
newRoute.governor_appeal = governor_appeal;
|
||||
}
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
const newId = await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
navigate(-1);
|
||||
navigate(`/route/${newId}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Произошла ошибка при создании маршрута");
|
||||
|
||||
@@ -6,10 +6,10 @@ import { observer } from "mobx-react-lite";
|
||||
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { Box, CircularProgress, Tooltip } from "@mui/material";
|
||||
|
||||
export const RouteListPage = observer(() => {
|
||||
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||
const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
@@ -38,6 +38,9 @@ export const RouteListPage = observer(() => {
|
||||
await getCarriers("zh");
|
||||
await getRoutes();
|
||||
setIsLoading(false);
|
||||
|
||||
const routeIds = routeStore.routes.data.map((r) => r.id);
|
||||
loadCounts(routeIds);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
@@ -128,6 +131,42 @@ export const RouteListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "sightCount",
|
||||
headerName: "Достопримечательности",
|
||||
width: 180,
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params: any) => (
|
||||
<Tooltip title="Количество привязанных достопримечательностей">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "stationCount",
|
||||
headerName: "Остановки",
|
||||
width: 120,
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params: any) => (
|
||||
<Tooltip title="Количество привязанных остановок">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(canShowActionsColumn ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
@@ -195,8 +234,10 @@ export const RouteListPage = observer(() => {
|
||||
route_sys_number: route.route_sys_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
route_name: route.route_name,
|
||||
sightCount: sightCounts.has(route.id) ? sightCounts.get(route.id) : null,
|
||||
stationCount: stationCounts.has(route.id) ? stationCounts.get(route.id) : null,
|
||||
}));
|
||||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
|
||||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, stationCounts.size, countsLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -230,6 +271,7 @@ export const RouteListPage = observer(() => {
|
||||
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteRoutes}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -141,6 +141,7 @@ export function RightSidebar() {
|
||||
bgcolor="primary.main"
|
||||
border="1px solid #e0e0e0"
|
||||
borderRadius={2}
|
||||
zIndex={2}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
Настройка маршрута
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Stack, Typography, Box, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { Landmark } from "lucide-react";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { RouteWidget } from "./webgl-prototype/RouteWidget";
|
||||
|
||||
export function Widgets() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
@@ -13,22 +14,11 @@ export function Widgets() {
|
||||
position="absolute"
|
||||
top={32}
|
||||
left={32}
|
||||
zIndex={2}
|
||||
sx={{ pointerEvents: "none" }}
|
||||
>
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={361}
|
||||
height={96}
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Остановка
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* Виджет маршрута */}
|
||||
<RouteWidget />
|
||||
|
||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||
<Stack
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.route-widget-label.marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
.route-widget-subtitle.marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.route-widget {
|
||||
width: 361px;
|
||||
height: 96px;
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
z-index: 10000001;
|
||||
}
|
||||
|
||||
.route-widget-number {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
min-width: 94px;
|
||||
max-width: 100px;
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 70px;
|
||||
padding: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.route-widget-content {
|
||||
overflow: hidden;
|
||||
width: 257px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 13px;
|
||||
margin-left: 109px;
|
||||
margin-right: 9px;
|
||||
}
|
||||
|
||||
.route-widget-label {
|
||||
white-space: nowrap;
|
||||
font-size: 24px;
|
||||
margin: 1px 0;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.route-widget-label--medium {
|
||||
font-size: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.route-widget-label--small {
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.route-widget-label--xsmall {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.route-widget-subtitle {
|
||||
white-space: nowrap;
|
||||
color: #cbcbcb;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import styles from "./RouteWidget.module.css";
|
||||
import { useMapData } from "../MapDataContext";
|
||||
import { languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const shouldAnimate = (text: string | undefined, maxLength: number) =>
|
||||
(text?.length ?? 0) > maxLength;
|
||||
|
||||
const getLabelSizeClass = (text: string | undefined) => {
|
||||
const length = text?.length ?? 0;
|
||||
if (length <= 40) return "";
|
||||
if (length <= 60) return styles["route-widget-label--medium"];
|
||||
if (length <= 80) return styles["route-widget-label--small"];
|
||||
return styles["route-widget-label--xsmall"];
|
||||
};
|
||||
|
||||
export const RouteWidget = observer(() => {
|
||||
const { routeData, stationData } = useMapData();
|
||||
const { language } = languageStore;
|
||||
|
||||
const stations = stationData?.[language] ?? stationData?.["ru"] ?? [];
|
||||
const stationsRu = stationData?.["ru"] ?? [];
|
||||
|
||||
const startStation = stations[0];
|
||||
const endStation = stations[stations.length - 1];
|
||||
|
||||
const startStationRu = stationsRu[0];
|
||||
const endStationRu = stationsRu[stationsRu.length - 1];
|
||||
|
||||
const enSubtitle = `${startStationRu?.name ?? ""} - ${endStationRu?.name ?? ""}`;
|
||||
const zhSubtitle = `${startStation?.name ?? ""} - ${endStation?.name ?? ""}`;
|
||||
const subtitle = language === "zh" ? zhSubtitle : enSubtitle;
|
||||
|
||||
return (
|
||||
<div className={styles["route-widget"]} style={{ position: "relative" }}>
|
||||
<div className={styles["route-widget-number"]}>
|
||||
{routeData?.route_sys_number || ""}
|
||||
</div>
|
||||
<div className={styles["route-widget-content"]}>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-label"],
|
||||
shouldAnimate(startStation?.name, 18) ? styles["marquee"] : "",
|
||||
getLabelSizeClass(startStation?.name),
|
||||
].join(" ")}
|
||||
title={startStation?.name}
|
||||
>
|
||||
{startStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-label"],
|
||||
shouldAnimate(endStation?.name, 18) ? styles["marquee"] : "",
|
||||
getLabelSizeClass(endStation?.name),
|
||||
].join(" ")}
|
||||
title={endStation?.name}
|
||||
>
|
||||
{endStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-subtitle"],
|
||||
shouldAnimate(subtitle, 50) ? styles["marquee"] : "",
|
||||
].join(" ")}
|
||||
title={subtitle}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1910,7 +1910,8 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
const stationIconSizePercent =
|
||||
liveStationIconSizes.get(station.id) ??
|
||||
(typeof station.icon_size === "number" && Number.isFinite(station.icon_size)
|
||||
(typeof station.icon_size === "number" &&
|
||||
Number.isFinite(station.icon_size)
|
||||
? station.icon_size
|
||||
: 100);
|
||||
const iconSizePx = Math.max(
|
||||
@@ -2277,6 +2278,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{stationData.ru.map((station, index) => {
|
||||
@@ -2706,7 +2708,8 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
? camera.scale /
|
||||
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
||||
: 1;
|
||||
const sightIconSizePercent = sight.is_default_icon === false
|
||||
const sightIconSizePercent =
|
||||
sight.is_default_icon === false
|
||||
? (liveSightIconSizes.get(sight.id) ??
|
||||
(typeof sight.icon_size === "number" &&
|
||||
Number.isFinite(sight.icon_size)
|
||||
@@ -2723,7 +2726,10 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
resizingSightIconId === sight.id);
|
||||
const iconLeft = cssX - iconSize;
|
||||
const iconTop = cssY - iconSize;
|
||||
const sightZoomClampedScale = Math.min(Math.max(camera.scale, 1), 3);
|
||||
const sightZoomClampedScale = Math.min(
|
||||
Math.max(camera.scale, 1),
|
||||
3,
|
||||
);
|
||||
const sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4;
|
||||
const labelHeight = 24 * sightScaleFactor;
|
||||
const labelPadding = 6 * sightScaleFactor;
|
||||
|
||||
@@ -185,6 +185,7 @@ export const SightListPage = observer(() => {
|
||||
onRowDoubleClick={(params) => canWriteSights && navigate(`/sight/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteSights}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
||||
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||
|
||||
const LOW_STORAGE_THRESHOLD_GB = 10;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const SnapshotListPage = observer(() => {
|
||||
restoreSnapshot,
|
||||
storageInfo,
|
||||
getStorageInfo,
|
||||
createEmptySnapshot,
|
||||
} = snapshotStore;
|
||||
const canWriteDevices = authStore.canWrite("devices");
|
||||
const canCreateSnapshot =
|
||||
@@ -42,6 +43,9 @@ export const SnapshotListPage = observer(() => {
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
||||
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
||||
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
@@ -167,6 +171,19 @@ export const SnapshotListPage = observer(() => {
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{canCreateSnapshot && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={isLowStorage}
|
||||
onClick={() => {
|
||||
setEmptySnapshotName("");
|
||||
setIsEmptySnapshotModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Создать пустой снапшот
|
||||
</Button>
|
||||
)}
|
||||
{canCreateSnapshot && (
|
||||
<CreateButton
|
||||
label="Создать экспорт медиа"
|
||||
@@ -175,6 +192,7 @@ export const SnapshotListPage = observer(() => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{usedGB != null && totalGB != null && (
|
||||
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
@@ -301,6 +319,46 @@ export const SnapshotListPage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={isEmptySnapshotModalOpen}
|
||||
onClose={() => setIsEmptySnapshotModalOpen(false)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Создать пустой снапшот</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={emptySnapshotName}
|
||||
onChange={(e) => setEmptySnapshotName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsEmptySnapshotModalOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
|
||||
onClick={async () => {
|
||||
setIsCreatingEmpty(true);
|
||||
try {
|
||||
await createEmptySnapshot(emptySnapshotName);
|
||||
await getSnapshots();
|
||||
setIsEmptySnapshotModalOpen(false);
|
||||
} finally {
|
||||
setIsCreatingEmpty(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isCreatingEmpty ? <CircularProgress size={20} /> : "Создать"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<SnapshotRestore
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -68,9 +68,9 @@ export const StationCreatePage = observer(() => {
|
||||
const executeCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createStation();
|
||||
const data = await createStation();
|
||||
toast.success("Остановка успешно создана");
|
||||
navigate("/station");
|
||||
navigate(`/station/${data.id}/edit`);
|
||||
} catch (error) {
|
||||
console.error("Error creating station:", error);
|
||||
toast.error("Ошибка при создании остановки");
|
||||
|
||||
@@ -226,6 +226,7 @@ export const StationListPage = observer(() => {
|
||||
onRowDoubleClick={(params) => canWriteStations && navigate(`/station/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteStations}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -326,6 +326,9 @@ export const UserEditPage = observer(() => {
|
||||
if (!next.includes("snapshot_create")) {
|
||||
next.push("snapshot_create");
|
||||
}
|
||||
if (!next.includes("devices_maintenance_rw")) {
|
||||
next.push("devices_maintenance_rw");
|
||||
}
|
||||
next.push("admin");
|
||||
return next;
|
||||
});
|
||||
@@ -347,7 +350,7 @@ export const UserEditPage = observer(() => {
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>
|
||||
Создание (snapshot_create)
|
||||
Доп. права
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -386,6 +389,8 @@ export const UserEditPage = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const isDevicesResource = key === "devices";
|
||||
|
||||
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||
if (!isSnapshotResource) {
|
||||
return;
|
||||
@@ -400,6 +405,13 @@ export const UserEditPage = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleMaintenanceChange = (checked: boolean) => {
|
||||
setLocalRoles((prev) => {
|
||||
const without = prev.filter((r) => r !== "devices_maintenance_rw");
|
||||
return checked ? [...without, "devices_maintenance_rw"] : without;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell>{label}</TableCell>
|
||||
@@ -447,6 +459,14 @@ export const UserEditPage = observer(() => {
|
||||
handleSnapshotCreateChange(e.target.checked)
|
||||
}
|
||||
size="small"
|
||||
title="Разрешает создавать новые снапшоты"
|
||||
/>
|
||||
) : isDevicesResource ? (
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Разрешает переводить устройства в режим технического обслуживания"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -153,6 +153,7 @@ export const UserListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteUsers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -188,6 +188,7 @@ export const VehicleListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteVehicles}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authStore } from "@shared";
|
||||
import { authStore, snapshotStore } from "@shared";
|
||||
import {
|
||||
Power,
|
||||
LucideIcon,
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
Split,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
RefreshCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import carrierIcon from "./carrier.svg";
|
||||
|
||||
@@ -165,6 +167,15 @@ export const NAVIGATION_ITEMS: {
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
{
|
||||
id: "clear-cache",
|
||||
label: "Очистить кэш",
|
||||
icon: RefreshCcw,
|
||||
onClick: () => {
|
||||
snapshotStore.clearStoreCache();
|
||||
toast.success("Кэш очищен");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Выйти",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
Language,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -232,6 +233,7 @@ export const ArticleSelectOrCreateDialog = observer(
|
||||
return;
|
||||
}
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
const response = await authInstance.post("/article", {
|
||||
translations: {
|
||||
heading: {
|
||||
@@ -245,6 +247,7 @@ export const ArticleSelectOrCreateDialog = observer(
|
||||
zh: newArticleData.zh.body || "Новый текст (ZH)",
|
||||
},
|
||||
},
|
||||
...(cityId ? { city_id: cityId } : {}),
|
||||
});
|
||||
|
||||
const { id } = response.data;
|
||||
@@ -519,9 +522,12 @@ export const ArticleSelectOrCreateDialog = observer(
|
||||
languageStore.setLanguage("ru");
|
||||
};
|
||||
|
||||
const filteredArticles = articles[modalLanguage].filter((article) =>
|
||||
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
const filteredArticles = articles[modalLanguage].filter((article) => {
|
||||
if (!article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
if (cityId && article.city_id !== cityId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { articlesStore, authInstance, languageStore } from "@shared";
|
||||
import { articlesStore, authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -116,12 +116,16 @@ export const SelectArticleModal = observer(
|
||||
}
|
||||
};
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
const filteredArticles = articles[languageStore.language].filter(
|
||||
(article) => !linkedArticleIds.includes(article.id)
|
||||
(article) => {
|
||||
if (linkedArticleIds.includes(article.id)) return false;
|
||||
if (searchQuery && !article.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
if (cityId && article.city_id !== cityId) return false;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// .filter((article) =>
|
||||
// article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
// );
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Media, mediaStore } from "@shared";
|
||||
import { Media, mediaStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -92,11 +92,17 @@ export const SelectMediaDialog = observer(
|
||||
};
|
||||
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
let filteredMedia = media
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id))
|
||||
.filter((mediaItem) =>
|
||||
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
)
|
||||
.filter((mediaItem) => {
|
||||
if (!cityId) return true;
|
||||
return mediaItem.city_id === cityId;
|
||||
});
|
||||
|
||||
if (mediaType) {
|
||||
filteredMedia = filteredMedia.filter(
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
editSightStore,
|
||||
generateDefaultMediaName,
|
||||
clearBlobAndGLTFCache,
|
||||
authStore,
|
||||
snapshotStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -247,12 +250,16 @@ export const UploadMediaDialog = observer(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const uploadStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const effectiveMediaType = hardcodeType
|
||||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||||
: mediaType;
|
||||
|
||||
const media = await uploadMedia(
|
||||
mediaFilename,
|
||||
hardcodeType
|
||||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||||
: mediaType,
|
||||
effectiveMediaType,
|
||||
mediaFile,
|
||||
mediaName
|
||||
);
|
||||
@@ -263,6 +270,40 @@ export const UploadMediaDialog = observer(
|
||||
await afterUpload(media);
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveMediaType === 2) {
|
||||
const uploadDurationSec = Math.round((Date.now() - uploadStartTime) / 1000);
|
||||
const minutes = Math.floor(uploadDurationSec / 60);
|
||||
const seconds = uploadDurationSec % 60;
|
||||
const durationStr = minutes > 0
|
||||
? `${minutes} мин ${seconds} сек`
|
||||
: `${seconds} сек`;
|
||||
|
||||
const fileSizeMb = mediaFile.size / (1024 * 1024);
|
||||
const fileSizeStr = fileSizeMb >= 1024
|
||||
? `${(fileSizeMb / 1024).toFixed(2)} ГБ`
|
||||
: `${fileSizeMb.toFixed(1)} МБ`;
|
||||
|
||||
if (authStore.canRead("snapshots")) {
|
||||
try {
|
||||
await snapshotStore.getStorageInfo();
|
||||
const storage = snapshotStore.storageInfo;
|
||||
if (storage) {
|
||||
toast.success(
|
||||
`Видео (${fileSizeStr}) загружено за ${durationStr}. Свободно на диске: ${storage.available_disk_space_gb.toFixed(2)} ГБ из ${storage.total_disk_space_gb.toFixed(2)} ГБ`,
|
||||
{ autoClose: 8000 }
|
||||
);
|
||||
} else {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
} catch {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
} else {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Article = {
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
city_id?: number | null;
|
||||
ru?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type City = {
|
||||
country: string;
|
||||
country_code: string;
|
||||
arms: string;
|
||||
weather_city_code?: number;
|
||||
};
|
||||
|
||||
export type CashedCities = {
|
||||
@@ -132,6 +133,7 @@ class CityStore {
|
||||
createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
@@ -159,9 +161,13 @@ class CityStore {
|
||||
};
|
||||
};
|
||||
|
||||
setCreateCityWeatherCode = (weather_city_code: number) => {
|
||||
this.createCityData = { ...this.createCityData, weather_city_code };
|
||||
};
|
||||
|
||||
async createCity() {
|
||||
const language = languageStore.language as Language;
|
||||
const { country_code, arms } = this.createCityData;
|
||||
const { country_code, arms, weather_city_code } = this.createCityData;
|
||||
const { name } = this.createCityData[language];
|
||||
|
||||
if (!name || !country_code) {
|
||||
@@ -178,6 +184,7 @@ class CityStore {
|
||||
)?.name || "",
|
||||
country_code,
|
||||
...(arms ? { arms } : {}),
|
||||
weather_city_code: weather_city_code ?? 0,
|
||||
};
|
||||
|
||||
const cityResponse = await languageInstance(language).post(
|
||||
@@ -232,6 +239,7 @@ class CityStore {
|
||||
this.createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: { name: "" },
|
||||
en: { name: "" },
|
||||
zh: { name: "" },
|
||||
@@ -246,6 +254,7 @@ class CityStore {
|
||||
editCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
@@ -267,16 +276,19 @@ class CityStore {
|
||||
...this.editCityData,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
|
||||
[language]: {
|
||||
name: name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
setEditCityWeatherCode = (weather_city_code: number) => {
|
||||
this.editCityData = { ...this.editCityData, weather_city_code };
|
||||
};
|
||||
|
||||
editCity = async (code: string) => {
|
||||
for (const language of ["ru", "en", "zh"]) {
|
||||
const { country_code, arms } = this.editCityData;
|
||||
const { country_code, arms, weather_city_code } = this.editCityData;
|
||||
const { name } = this.editCityData[language as keyof CashedCities];
|
||||
const { countries } = countryStore;
|
||||
|
||||
@@ -289,6 +301,7 @@ class CityStore {
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
weather_city_code: weather_city_code ?? 0,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
authInstance,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@@ -40,6 +41,7 @@ type SightCommonInfo = {
|
||||
left_article: number;
|
||||
preview_media: string | null;
|
||||
video_preview: string | null;
|
||||
preview_font_size?: number;
|
||||
};
|
||||
|
||||
type SightBaseInfo = SightCommonInfo & {
|
||||
@@ -128,6 +130,7 @@ class CreateSightStore {
|
||||
zh: articleZhData.body,
|
||||
},
|
||||
},
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const { id } = articleRes.data;
|
||||
|
||||
@@ -184,7 +187,7 @@ class CreateSightStore {
|
||||
index: number,
|
||||
language: Language,
|
||||
heading: string,
|
||||
body: string
|
||||
body: string,
|
||||
) => {
|
||||
if (this.sight[language].right[index]) {
|
||||
this.sight[language].right[index].heading = heading;
|
||||
@@ -195,13 +198,13 @@ class CreateSightStore {
|
||||
unlinkRightAritcle = (articleId: number) => {
|
||||
runInAction(() => {
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.en.right = this.sight.en.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.zh.right = this.sight.zh.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -211,13 +214,13 @@ class CreateSightStore {
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
runInAction(() => {
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.en.right = this.sight.en.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.zh.right = this.sight.zh.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -235,7 +238,7 @@ class CreateSightStore {
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
const article = this.sight[lang].right.find(
|
||||
(a) => a.id === articleId
|
||||
(a) => a.id === articleId,
|
||||
);
|
||||
if (article) {
|
||||
if (!article.media) article.media = [];
|
||||
@@ -257,7 +260,7 @@ class CreateSightStore {
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
const article = this.sight[lang].right.find(
|
||||
(a) => a.id === articleId
|
||||
(a) => a.id === articleId,
|
||||
);
|
||||
if (article && article.media) {
|
||||
article.media = article.media.filter((m) => m.id !== mediaId);
|
||||
@@ -322,13 +325,13 @@ class CreateSightStore {
|
||||
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
articlesStore.articles.en = articlesStore.articles.en.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
articlesStore.articles.zh = articlesStore.articles.zh.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
this.unlinkLeftArticle();
|
||||
@@ -345,6 +348,7 @@ class CreateSightStore {
|
||||
const response = await languageInstance("ru").post("/article", {
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const newLeftArticleId = response.data.id;
|
||||
|
||||
@@ -431,7 +435,7 @@ class CreateSightStore {
|
||||
|
||||
updateSightInfo = (
|
||||
content: Partial<SightLanguageInfo | SightCommonInfo>,
|
||||
language?: Language
|
||||
language?: Language,
|
||||
) => {
|
||||
this.needLeaveAgree = true;
|
||||
if (language) {
|
||||
@@ -448,6 +452,7 @@ class CreateSightStore {
|
||||
const res = await languageInstance("ru").post("/article", {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
finalLeftArticleId = res.data.id;
|
||||
await languageInstance("en").patch(`/article/${finalLeftArticleId}`, {
|
||||
@@ -464,15 +469,15 @@ class CreateSightStore {
|
||||
) {
|
||||
await languageInstance("ru").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body },
|
||||
);
|
||||
await languageInstance("en").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body }
|
||||
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body },
|
||||
);
|
||||
await languageInstance("zh").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,7 +493,7 @@ class CreateSightStore {
|
||||
}
|
||||
}
|
||||
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
|
||||
(a) => a.id
|
||||
(a) => a.id,
|
||||
);
|
||||
|
||||
const sightPayload = {
|
||||
@@ -508,16 +513,17 @@ class CreateSightStore {
|
||||
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
|
||||
preview_media: this.sight.preview_media,
|
||||
video_preview: this.sight.video_preview,
|
||||
preview_font_size: this.sight.preview_font_size,
|
||||
};
|
||||
|
||||
const response = await languageInstance(primaryLanguage).post(
|
||||
"/sight",
|
||||
sightPayload
|
||||
sightPayload,
|
||||
);
|
||||
const newSightId = response.data.id;
|
||||
|
||||
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
|
||||
(l) => l !== primaryLanguage
|
||||
(l) => l !== primaryLanguage,
|
||||
);
|
||||
for (const lang of otherLanguages) {
|
||||
await languageInstance(lang).patch(`/sight/${newSightId}`, {
|
||||
@@ -547,7 +553,10 @@ class CreateSightStore {
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.needLeaveAgree = false;
|
||||
});
|
||||
|
||||
return newSightId;
|
||||
};
|
||||
|
||||
@@ -555,13 +564,16 @@ class CreateSightStore {
|
||||
filename: string,
|
||||
type: number,
|
||||
file: File,
|
||||
media_name?: string
|
||||
media_name?: string,
|
||||
): Promise<MediaItem> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("filename", filename);
|
||||
if (media_name) formData.append("media_name", media_name);
|
||||
formData.append("type", type.toString());
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
formData.append("city_id", selectedCityStore.selectedCityId.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
@@ -585,7 +597,7 @@ class CreateSightStore {
|
||||
createLinkWithLeftArticle = async (media: MediaItem) => {
|
||||
if (!this.sight.left_article || this.sight.left_article === 10000000) {
|
||||
console.warn(
|
||||
"Left article not selected or is a placeholder. Cannot link media yet."
|
||||
"Left article not selected or is a placeholder. Cannot link media yet.",
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -618,7 +630,7 @@ class CreateSightStore {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
if (this.sight[lang].left.media) {
|
||||
this.sight[lang].left.media = this.sight[lang].left.media.filter(
|
||||
(m) => m.id !== mediaId
|
||||
(m) => m.id !== mediaId,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -634,13 +646,13 @@ class CreateSightStore {
|
||||
|
||||
const sortArticles = (existing: any[]) => {
|
||||
const articleMap = new Map(
|
||||
existing.map((article) => [article.id, article])
|
||||
existing.map((article) => [article.id, article]),
|
||||
);
|
||||
return articlesIds
|
||||
.map((id) => articleMap.get(id))
|
||||
.filter(
|
||||
(article): article is (typeof existing)[number] =>
|
||||
article !== undefined
|
||||
article !== undefined,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Language,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@@ -270,6 +271,7 @@ class EditSightStore {
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
createdLeftArticleId = response.data.id;
|
||||
await languageInstance("en").patch(`/article/${createdLeftArticleId}`, {
|
||||
@@ -412,6 +414,7 @@ class EditSightStore {
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
|
||||
this.sight.common.left_article = response.data.id;
|
||||
@@ -510,6 +513,9 @@ class EditSightStore {
|
||||
formData.append("media_name", media_name);
|
||||
}
|
||||
formData.append("type", type.toString());
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
formData.append("city_id", selectedCityStore.selectedCityId.toString());
|
||||
}
|
||||
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
this.fileToUpload = null;
|
||||
@@ -652,6 +658,7 @@ class EditSightStore {
|
||||
zh: articleZhData.body,
|
||||
},
|
||||
},
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const { id } = articleId.data;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export type Media = {
|
||||
filename: string;
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
city_id?: number | null;
|
||||
};
|
||||
|
||||
class MediaStore {
|
||||
@@ -75,10 +76,11 @@ class MediaStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
createMedia = async (name: string, type: string) => {
|
||||
createMedia = async (name: string, type: string, cityId?: number | null) => {
|
||||
const response = await authInstance.post("/media", {
|
||||
media_name: name,
|
||||
media_type: type,
|
||||
...(cityId ? { city_id: cityId } : {}),
|
||||
});
|
||||
runInAction(() => {
|
||||
this.media.push(response.data);
|
||||
|
||||
@@ -53,7 +53,7 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
createRoute = async (route: any) => {
|
||||
createRoute = async (route: any): Promise<number> => {
|
||||
const response = await authInstance.post("/route", route);
|
||||
const id = response.data.id;
|
||||
|
||||
@@ -61,6 +61,8 @@ class RouteStore {
|
||||
this.route[id] = { ...route, id };
|
||||
this.routes.data = [...this.routes.data, { ...route, id }];
|
||||
});
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
deleteRoute = async (id: number) => {
|
||||
@@ -200,6 +202,49 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
sightCounts: Map<number, number> = new Map();
|
||||
stationCounts: Map<number, number> = new Map();
|
||||
countsLoading = false;
|
||||
|
||||
loadCounts = async (routeIds: number[]) => {
|
||||
if (routeIds.length === 0) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.countsLoading = true;
|
||||
});
|
||||
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < routeIds.length; i += batchSize) {
|
||||
const batch = routeIds.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.flatMap((id) => [
|
||||
authInstance.get(`/route/${id}/sight/count`).then((res) => ({ id, type: "sight", data: res.data })),
|
||||
authInstance.get(`/route/${id}/station/count`).then((res) => ({ id, type: "station", data: res.data })),
|
||||
])
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
const { id, type, data } = result.value;
|
||||
let count = 0;
|
||||
if (typeof data === "number") {
|
||||
count = data;
|
||||
} else if (data && typeof data === "object") {
|
||||
count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0);
|
||||
}
|
||||
if (type === "sight") this.sightCounts.set(id, count);
|
||||
else this.stationCounts.set(id, count);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.countsLoading = false;
|
||||
});
|
||||
};
|
||||
|
||||
selectedStationId = 0;
|
||||
|
||||
setSelectedStationId = (id: number) => {
|
||||
|
||||
@@ -49,6 +49,50 @@ class SnapshotStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
clearStoreCache = () => {
|
||||
runInAction(() => {
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
articlesStore.articlePreview = {};
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
countryStore.countries = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
stationsStore.stationLists = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
stationsStore.stationPreview = {};
|
||||
|
||||
sightsStore.sights = [];
|
||||
sightsStore.sight = null;
|
||||
|
||||
routeStore.routes = { data: [], loaded: false };
|
||||
|
||||
vehicleStore.vehicles = { data: [], loaded: false };
|
||||
|
||||
userStore.users = { data: [], loaded: false };
|
||||
|
||||
mediaStore.media = [];
|
||||
mediaStore.oneMedia = null;
|
||||
});
|
||||
};
|
||||
|
||||
private clearAllCaches = () => {
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
@@ -297,6 +341,12 @@ class SnapshotStore {
|
||||
await authInstance.post(`/snapshots/${id}/restore`);
|
||||
};
|
||||
|
||||
createEmptySnapshot = async (name: string) => {
|
||||
await authInstance.post(`/snapshots/empty`, {
|
||||
name: name.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
createSnapshot = async (name: string) => {
|
||||
this.lastRequestId = uuidv4();
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ class StationsStore {
|
||||
this.stationLists[language].data.push(response.data);
|
||||
});
|
||||
|
||||
const stationId = response.data.id;
|
||||
const stationId: number = response.data.id;
|
||||
|
||||
for (const lang of ["ru", "en", "zh"].filter(
|
||||
(lang) => lang !== language
|
||||
|
||||
@@ -129,6 +129,8 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
|
||||
|
||||
export const DevicesTable = observer(() => {
|
||||
const canWriteDevices = authStore.canWrite("devices");
|
||||
const canMaintenanceDevices = authStore.hasRole("devices_maintenance_rw");
|
||||
const isMaintenanceOnly = canMaintenanceDevices && !canWriteDevices;
|
||||
const {
|
||||
getDevices,
|
||||
setSelectedDevice,
|
||||
@@ -706,9 +708,24 @@ export const DevicesTable = observer(() => {
|
||||
demoConfirmSubmitting,
|
||||
routes,
|
||||
canWriteDevices,
|
||||
isMaintenanceOnly,
|
||||
],
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
if (isMaintenanceOnly) {
|
||||
return columns.filter((c) =>
|
||||
["model", "tail_number", "maintenance_mode_on"].includes(c.field),
|
||||
);
|
||||
}
|
||||
if (!canWriteDevices) {
|
||||
return columns.filter(
|
||||
(c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled",
|
||||
);
|
||||
}
|
||||
return columns;
|
||||
}, [columns, isMaintenanceOnly, canWriteDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -900,9 +917,10 @@ export const DevicesTable = observer(() => {
|
||||
<Box sx={{ p: 0 }}>
|
||||
<DataGrid
|
||||
rows={groupRows}
|
||||
columns={columns}
|
||||
columns={visibleColumns}
|
||||
checkboxSelection={canWriteDevices}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Save } from "lucide-react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
@@ -50,6 +51,7 @@ export const CreateInformationTab = observer(
|
||||
const { language } = languageStore;
|
||||
const { sight, updateSightInfo, createSight } = createSightStore;
|
||||
const data = sight[language];
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setCity] = useState<number>(sight.city_id ?? 0);
|
||||
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
|
||||
@@ -173,14 +175,16 @@ export const CreateInformationTab = observer(
|
||||
return;
|
||||
}
|
||||
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
toast.success("Достопримечательность создана");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
toast.success("Достопримечательность создана");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
};
|
||||
|
||||
const handleCancelSave = () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@widgets";
|
||||
import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -41,6 +42,7 @@ export const CreateLeftTab = observer(
|
||||
uploadMediaOpen,
|
||||
setUploadMediaOpen,
|
||||
} = editSightStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { language } = languageStore;
|
||||
const token = localStorage.getItem("token");
|
||||
@@ -449,8 +451,9 @@ export const CreateLeftTab = observer(
|
||||
startIcon={<Save color="white" size={18} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
toast.success("Страница создана");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Slider,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
BackButton,
|
||||
createSightStore,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
TabPanel,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
@@ -22,17 +20,18 @@ import {
|
||||
LanguageSwitcher,
|
||||
MediaArea,
|
||||
MediaAreaForSight,
|
||||
ReactMarkdownComponent,
|
||||
ReactMarkdownEditor,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MediaViewer } from "../../MediaViewer/index";
|
||||
import { toast } from "react-toastify";
|
||||
import { authInstance } from "@shared";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import { SightFramePreview } from "../RightWidgetTab/SightFramePreview";
|
||||
|
||||
type MediaItemShared = {
|
||||
id: string;
|
||||
@@ -43,7 +42,6 @@ type MediaItemShared = {
|
||||
|
||||
export const CreateRightTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const {
|
||||
sight,
|
||||
createNewRightArticle,
|
||||
@@ -54,21 +52,20 @@ export const CreateRightTab = observer(
|
||||
deleteRightArticleMedia,
|
||||
unlinkRightAritcle,
|
||||
deleteRightArticle,
|
||||
linkExistingRightArticle,
|
||||
createSight,
|
||||
clearCreateSight,
|
||||
updateRightArticles,
|
||||
updateSightInfo,
|
||||
} = createSightStore;
|
||||
const { language } = languageStore;
|
||||
const navigate = useNavigate();
|
||||
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
|
||||
editSightStore;
|
||||
|
||||
const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [type, setType] = useState<"article" | "media">("media");
|
||||
const [previewSection, setPreviewSection] = useState<number>(-1);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
|
||||
useState(false);
|
||||
@@ -77,12 +74,34 @@ export const CreateRightTab = observer(
|
||||
>(null);
|
||||
|
||||
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
|
||||
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const insertNewline = () => {
|
||||
const input = shortNameRef.current;
|
||||
const currentValue = sight[language].name || "";
|
||||
if (!input) {
|
||||
updateSightInfo({ name: currentValue + "\n" }, language);
|
||||
return;
|
||||
}
|
||||
const start = input.selectionStart ?? currentValue.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
const newValue =
|
||||
currentValue.slice(0, start) + "\n" + currentValue.slice(end);
|
||||
updateSightInfo({ name: newValue }, language);
|
||||
requestAnimationFrame(() => {
|
||||
if (shortNameRef.current) {
|
||||
shortNameRef.current.selectionStart = start + 1;
|
||||
shortNameRef.current.selectionEnd = start + 1;
|
||||
shortNameRef.current.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sight.preview_media) {
|
||||
const fetchMedia = async () => {
|
||||
const response = await authInstance.get(
|
||||
`/media/${sight.preview_media}`
|
||||
`/media/${sight.preview_media}`,
|
||||
);
|
||||
setPreviewMedia(response.data);
|
||||
};
|
||||
@@ -97,24 +116,17 @@ export const CreateRightTab = observer(
|
||||
) {
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}
|
||||
}, [language, sight[language].right, activeArticleIndex]);
|
||||
|
||||
const openMenu = Boolean(anchorEl);
|
||||
const handleClickMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleCloseMenu = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree);
|
||||
toast.success("Достопримечательность успешно создана!");
|
||||
clearCreateSight();
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`);
|
||||
} catch (error) {
|
||||
console.error("Failed to save sight:", error);
|
||||
toast.error("Ошибка при создании достопримечательности.");
|
||||
@@ -124,48 +136,26 @@ export const CreateRightTab = observer(
|
||||
const handleDisplayArticleFromList = (idx: number) => {
|
||||
setActiveArticleIndex(idx);
|
||||
setType("article");
|
||||
setPreviewSection(idx);
|
||||
};
|
||||
|
||||
const handleCreateNewLocalArticle = async () => {
|
||||
handleCloseMenu();
|
||||
try {
|
||||
const newArticleId = await createNewRightArticle();
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(a) => a.id === newArticleId
|
||||
(a) => a.id === newArticleId,
|
||||
);
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
const resolvedIndex =
|
||||
newIndex > -1 ? newIndex : sight[language].right.length - 1;
|
||||
setActiveArticleIndex(resolvedIndex);
|
||||
setType("article");
|
||||
} else {
|
||||
setActiveArticleIndex(sight[language].right.length - 1);
|
||||
setType("article");
|
||||
}
|
||||
setPreviewSection(resolvedIndex);
|
||||
} catch (error) {
|
||||
toast.error("Не удалось создать новую статью.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectExistingArticleAndLink = async (
|
||||
selectedArticleId: number
|
||||
) => {
|
||||
try {
|
||||
const linkedArticleId = await linkExistingRightArticle(
|
||||
selectedArticleId
|
||||
);
|
||||
setSelectArticleDialogOpen(false);
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(a) => a.id === linkedArticleId
|
||||
);
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Не удалось привязать существующую статью.");
|
||||
}
|
||||
};
|
||||
|
||||
const currentRightArticle =
|
||||
activeArticleIndex !== null && sight[language].right[activeArticleIndex]
|
||||
? sight[language].right[activeArticleIndex]
|
||||
@@ -176,7 +166,7 @@ export const CreateRightTab = observer(
|
||||
};
|
||||
|
||||
const handleOpenSelectMediaDialog = (
|
||||
target: "sightPreview" | "rightArticle"
|
||||
target: "sightPreview" | "rightArticle",
|
||||
) => {
|
||||
setMediaTarget(target);
|
||||
setIsSelectMediaDialogOpen(true);
|
||||
@@ -220,11 +210,8 @@ export const CreateRightTab = observer(
|
||||
if (sourceIndex === destinationIndex) return;
|
||||
|
||||
const newRightArticles = [...sight[language].right];
|
||||
|
||||
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
|
||||
|
||||
newRightArticles.splice(destinationIndex, 0, movedArticle);
|
||||
|
||||
updateRightArticles(newRightArticles);
|
||||
};
|
||||
|
||||
@@ -248,18 +235,22 @@ export const CreateRightTab = observer(
|
||||
</div>
|
||||
|
||||
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
||||
<Box className="flex flex-col w-[75%] gap-2">
|
||||
<Box
|
||||
className="flex flex-col gap-2"
|
||||
sx={{ flexGrow: 1, minWidth: 0 }}
|
||||
>
|
||||
<Box className="w-full flex gap-2">
|
||||
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
|
||||
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
|
||||
<Box
|
||||
onClick={() => {
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}}
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
|
||||
type === "media"
|
||||
? "bg-green-300 font-semibold"
|
||||
: "bg-green-200"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Typography>Предпросмотр медиа</Typography>
|
||||
@@ -285,16 +276,19 @@ export const CreateRightTab = observer(
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${
|
||||
className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
|
||||
snapshot.isDragging
|
||||
? "shadow-lg"
|
||||
: ""
|
||||
? "shadow-lg bg-gray-200"
|
||||
: activeArticleIndex ===
|
||||
index &&
|
||||
type === "article"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleDisplayArticleFromList(
|
||||
index
|
||||
index,
|
||||
);
|
||||
setType("article");
|
||||
}}
|
||||
>
|
||||
<Box {...provided.dragHandleProps}>
|
||||
@@ -305,7 +299,7 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
),
|
||||
)
|
||||
: null}
|
||||
{provided.placeholder}
|
||||
@@ -317,33 +311,10 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
<button
|
||||
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center hover:bg-blue-600"
|
||||
onClick={handleClickMenu}
|
||||
aria-controls={openMenu ? "add-article-menu" : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenu ? "true" : undefined}
|
||||
onClick={handleCreateNewLocalArticle}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</button>
|
||||
<Menu
|
||||
id="add-article-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={openMenu}
|
||||
onClose={handleCloseMenu}
|
||||
MenuListProps={{ "aria-labelledby": "basic-button" }}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem onClick={handleCreateNewLocalArticle}>
|
||||
<Typography>Создать новую</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSelectArticleDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
}}
|
||||
>
|
||||
<Typography>Выбрать существующую статью</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{type === "article" && currentRightArticle ? (
|
||||
@@ -359,6 +330,7 @@ export const CreateRightTab = observer(
|
||||
unlinkRightAritcle(currentRightArticle.id);
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -369,9 +341,7 @@ export const CreateRightTab = observer(
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<Trash2 size={18} />}
|
||||
onClick={async () => {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
@@ -395,7 +365,7 @@ export const CreateRightTab = observer(
|
||||
activeArticleIndex,
|
||||
language,
|
||||
e.target.value,
|
||||
currentRightArticle.body
|
||||
currentRightArticle.body,
|
||||
)
|
||||
}
|
||||
variant="outlined"
|
||||
@@ -410,7 +380,7 @@ export const CreateRightTab = observer(
|
||||
activeArticleIndex,
|
||||
language,
|
||||
currentRightArticle.heading,
|
||||
mdValue || ""
|
||||
mdValue || "",
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -432,11 +402,10 @@ export const CreateRightTab = observer(
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : type === "media" ? (
|
||||
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
|
||||
<>
|
||||
{type === "media" && (
|
||||
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
|
||||
) : (
|
||||
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex justify-center items-center">
|
||||
{sight.preview_media && (
|
||||
<Box className="w-full h-full rounded-2xl relative flex items-center justify-center">
|
||||
{previewMedia && (
|
||||
<>
|
||||
<Box className="absolute top-4 right-4 z-10">
|
||||
@@ -447,8 +416,7 @@ export const CreateRightTab = observer(
|
||||
<X size={20} color="red" />
|
||||
</button>
|
||||
</Box>
|
||||
|
||||
<Box className="w-1/2 h-1/2">
|
||||
<Box className="w-1/2 h-1/2 flex justify-center items-center">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: previewMedia.id || "",
|
||||
@@ -461,8 +429,10 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!previewMedia && (
|
||||
{!sight.preview_media && (
|
||||
<Box className="w-full h-full flex justify-center items-center">
|
||||
<Box
|
||||
sx={{
|
||||
@@ -488,169 +458,104 @@ export const CreateRightTab = observer(
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Выберите статью слева или секцию "Предпросмотр медиа"
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="w-[25%] mr-10">
|
||||
{type === "article" && activeArticleIndex !== null && (
|
||||
<Paper
|
||||
className="flex-1 flex flex-col max-w-[500px]"
|
||||
sx={{
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={2}
|
||||
>
|
||||
<Box
|
||||
className=" overflow-hidden"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
flexShrink: 0,
|
||||
width: "550px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{sight[language].right[activeArticleIndex].media.length >
|
||||
0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
padding: "2px 2px 0px 2px",
|
||||
|
||||
"& img": {
|
||||
borderTopLeftRadius: "10px",
|
||||
borderTopRightRadius: "10px",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MediaViewer
|
||||
media={
|
||||
sight[language].right[activeArticleIndex].media[0]
|
||||
}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="white" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
wordBreak: "break-word",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderBottom: "1px solid #A89F90",
|
||||
boxShadow:
|
||||
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{sight[language].right[activeArticleIndex].heading ||
|
||||
"Выберите статью"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
minHeight: "200px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
"&": {
|
||||
scrollbarWidth: "none",
|
||||
},
|
||||
background:
|
||||
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{sight[language].right[activeArticleIndex].body ? (
|
||||
<ReactMarkdownComponent
|
||||
value={sight[language].right[activeArticleIndex].body}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
color="rgba(255,255,255,0.7)"
|
||||
sx={{ textAlign: "center", mt: 4 }}
|
||||
>
|
||||
Предпросмотр статьи появится здесь
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
flexWrap: "wrap",
|
||||
|
||||
gap: 1,
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow:
|
||||
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
{sight[language].right.length > 0 &&
|
||||
sight[language].right.map((article, index) => (
|
||||
<button
|
||||
className={`inline-block text-left text-xs text-white ${
|
||||
activeArticleIndex === index ? "underline" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveArticleIndex(index);
|
||||
setType("article");
|
||||
{type === "media" && (
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер шрифта превью (px)"
|
||||
size="small"
|
||||
value={sight.preview_font_size ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
updateSightInfo({ preview_font_size: undefined });
|
||||
return;
|
||||
}
|
||||
const val = Math.max(
|
||||
1,
|
||||
Math.min(300, Math.round(Number(raw))),
|
||||
);
|
||||
if (Number.isFinite(val)) {
|
||||
updateSightInfo({ preview_font_size: val });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{article.heading}
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
slotProps={{ input: { min: 1, max: 300 } }}
|
||||
sx={{ width: "200px" }}
|
||||
/>
|
||||
<Slider
|
||||
value={sight.preview_font_size ?? 40}
|
||||
min={1}
|
||||
max={300}
|
||||
step={1}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
updateSightInfo({ preview_font_size: newValue });
|
||||
}
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
label="Полное название (поддерживает перенос ↵)"
|
||||
multiline
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
size="small"
|
||||
value={sight[language].name}
|
||||
onChange={(e) =>
|
||||
updateSightInfo({ name: e.target.value }, language)
|
||||
}
|
||||
inputRef={shortNameRef}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={insertNewline}
|
||||
title="Вставить перенос строки"
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
fontSize: 18,
|
||||
p: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
↵
|
||||
</Button>
|
||||
</Stack>
|
||||
<SightFramePreview
|
||||
sightName={sight[language].name}
|
||||
previewMedia={previewMedia}
|
||||
articles={sight[language].right}
|
||||
onArticleSelect={(idx) => {
|
||||
handleDisplayArticleFromList(idx);
|
||||
}}
|
||||
previewFontSize={sight.preview_font_size}
|
||||
selectedSection={previewSection}
|
||||
onSectionChange={(section) => {
|
||||
setPreviewSection(section);
|
||||
if (section === -1) {
|
||||
setType("media");
|
||||
} else {
|
||||
handleDisplayArticleFromList(section);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -662,7 +567,6 @@ export const CreateRightTab = observer(
|
||||
right: 0,
|
||||
padding: 2,
|
||||
backgroundColor: "background.paper",
|
||||
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
@@ -680,12 +584,6 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<SelectArticleModal
|
||||
open={selectArticleDialogOpen}
|
||||
onClose={() => setSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleSelectExistingArticleAndLink}
|
||||
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
||||
/>
|
||||
<UploadMediaDialog
|
||||
open={uploadMediaOpen}
|
||||
onClose={() => {
|
||||
@@ -715,9 +613,18 @@ export const CreateRightTab = observer(
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
const idx = activeArticleIndex ?? 0;
|
||||
await deleteRightArticle(currentRightArticle?.id || 0);
|
||||
setIsDeleteModalOpen(false);
|
||||
if (idx > 0) {
|
||||
setActiveArticleIndex(idx - 1);
|
||||
setPreviewSection(idx - 1);
|
||||
setType("article");
|
||||
} else {
|
||||
setActiveArticleIndex(null);
|
||||
setPreviewSection(-1);
|
||||
setType("media");
|
||||
}
|
||||
toast.success("Статья удалена");
|
||||
} catch {
|
||||
toast.error("Не удалось удалить статью");
|
||||
@@ -727,5 +634,5 @@ export const CreateRightTab = observer(
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SelectArticleModal,
|
||||
UploadMediaDialog,
|
||||
Language,
|
||||
articlesStore,
|
||||
} from "@shared";
|
||||
import {
|
||||
LanguageSwitcher,
|
||||
@@ -85,31 +84,7 @@ export const LeftWidgetTab = observer(
|
||||
) => {
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
|
||||
const ruArticle = await articlesStore.getArticle(articleId, "ru");
|
||||
const enArticle = await articlesStore.getArticle(articleId, "en");
|
||||
const zhArticle = await articlesStore.getArticle(articleId, "zh");
|
||||
|
||||
updateSightInfo("ru", {
|
||||
left: {
|
||||
heading: ruArticle.data.heading,
|
||||
body: ruArticle.data.body,
|
||||
media: ruArticle.data.media || [],
|
||||
},
|
||||
});
|
||||
updateSightInfo("en", {
|
||||
left: {
|
||||
heading: enArticle.data.heading,
|
||||
body: enArticle.data.body,
|
||||
media: enArticle.data.media || [],
|
||||
},
|
||||
});
|
||||
updateSightInfo("zh", {
|
||||
left: {
|
||||
heading: zhArticle.data.heading,
|
||||
body: zhArticle.data.body,
|
||||
media: zhArticle.data.media || [],
|
||||
},
|
||||
});
|
||||
await editSightStore.getLeftArticle(articleId);
|
||||
|
||||
updateSightInfo(
|
||||
languageStore.language,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ReactMarkdownComponent } from "../../ReactMarkdown";
|
||||
import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary";
|
||||
@@ -34,6 +34,8 @@ interface SightFramePreviewProps {
|
||||
articles: Article[];
|
||||
onArticleSelect: (index: number) => void;
|
||||
previewFontSize?: number;
|
||||
selectedSection: number;
|
||||
onSectionChange: (section: number) => void;
|
||||
}
|
||||
|
||||
// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes
|
||||
@@ -153,11 +155,10 @@ export function SightFramePreview({
|
||||
articles,
|
||||
onArticleSelect,
|
||||
previewFontSize,
|
||||
selectedSection,
|
||||
onSectionChange,
|
||||
}: SightFramePreviewProps) {
|
||||
const token = localStorage.getItem("token") ?? "";
|
||||
|
||||
// -1 = intro (section 0 in SightFrame)
|
||||
const [selectedSection, setSelectedSection] = useState<number>(-1);
|
||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||
const threeViewControlRef = useRef<ThreeViewHandle | null>(null);
|
||||
|
||||
@@ -175,6 +176,17 @@ export function SightFramePreview({
|
||||
// Replicates processedSightName from SightFrame.jsx
|
||||
const processedSightName = useMemo(() => {
|
||||
if (!sightName) return sightName;
|
||||
|
||||
// Handle \n line breaks
|
||||
if (sightName.includes("\n")) {
|
||||
return sightName.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
const namePattern =
|
||||
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
||||
const parts = sightName.split(namePattern);
|
||||
@@ -199,10 +211,9 @@ export function SightFramePreview({
|
||||
// Replicates titleLineHeight from SightFrame.jsx
|
||||
const titleLineHeight = useMemo(() => {
|
||||
if (!sightName) return "120%";
|
||||
const textLength = sightName.length;
|
||||
const calculatedLineHeight = Math.max(
|
||||
100,
|
||||
Math.min(120, 120 - (textLength / 10) * 1)
|
||||
Math.min(120, 120 - (sightName.length / 10) * 1)
|
||||
);
|
||||
return `${calculatedLineHeight}%`;
|
||||
}, [sightName]);
|
||||
@@ -272,7 +283,7 @@ export function SightFramePreview({
|
||||
<button
|
||||
className="sfp-back-btn"
|
||||
type="button"
|
||||
onClick={() => setSelectedSection(-1)}
|
||||
onClick={() => onSectionChange(-1)}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
@@ -320,7 +331,7 @@ export function SightFramePreview({
|
||||
type="button"
|
||||
className={`sfp-sight-frame-menu-point${selectedSection === index ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedSection(index);
|
||||
onSectionChange(index);
|
||||
onArticleSelect(index);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Slider,
|
||||
Stack,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
BackButton,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
TabPanel,
|
||||
UploadMediaDialog,
|
||||
@@ -27,7 +24,7 @@ import {
|
||||
} from "@widgets";
|
||||
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { MediaViewer } from "../../MediaViewer/index";
|
||||
import {
|
||||
@@ -40,8 +37,6 @@ import { SightFramePreview } from "./SightFramePreview";
|
||||
|
||||
export const RightWidgetTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const {
|
||||
sight,
|
||||
updateRightArticleInfo,
|
||||
@@ -52,7 +47,6 @@ export const RightWidgetTab = observer(
|
||||
linkPreviewMedia,
|
||||
unlinkRightArticle,
|
||||
deleteRightArticle,
|
||||
linkArticle,
|
||||
deleteRightArticleMedia,
|
||||
createLinkWithRightArticle,
|
||||
setFileToUpload,
|
||||
@@ -61,6 +55,27 @@ export const RightWidgetTab = observer(
|
||||
} = editSightStore;
|
||||
|
||||
const [previewMedia, setPreviewMedia] = useState<any | null>(null);
|
||||
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const insertNewline = () => {
|
||||
const input = shortNameRef.current;
|
||||
const currentValue = sight[language].name || "";
|
||||
if (!input) {
|
||||
updateSightInfo(language, { name: currentValue + "\n" });
|
||||
return;
|
||||
}
|
||||
const start = input.selectionStart ?? currentValue.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
const newValue = currentValue.slice(0, start) + "\n" + currentValue.slice(end);
|
||||
updateSightInfo(language, { name: newValue });
|
||||
requestAnimationFrame(() => {
|
||||
if (shortNameRef.current) {
|
||||
shortNameRef.current.selectionStart = start + 1;
|
||||
shortNameRef.current.selectionEnd = start + 1;
|
||||
shortNameRef.current.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreviewMedia = async () => {
|
||||
@@ -94,22 +109,23 @@ export const RightWidgetTab = observer(
|
||||
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
|
||||
const [previewSection, setPreviewSection] = useState<number>(-1);
|
||||
const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false);
|
||||
const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] =
|
||||
useState(false);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleDeleteArticle = () => {
|
||||
deleteRightArticle(sight[language].right[activeArticleIndex || 0].id);
|
||||
const idx = activeArticleIndex || 0;
|
||||
deleteRightArticle(sight[language].right[idx].id);
|
||||
if (idx > 0) {
|
||||
setActiveArticleIndex(idx - 1);
|
||||
setPreviewSection(idx - 1);
|
||||
setType("article");
|
||||
} else {
|
||||
setActiveArticleIndex(null);
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
setPreviewSection(-1);
|
||||
setType("media");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectArticle = (index: number) => {
|
||||
@@ -119,7 +135,6 @@ export const RightWidgetTab = observer(
|
||||
const handleCreateNew = async () => {
|
||||
try {
|
||||
const newArticleId = await createNewRightArticle();
|
||||
handleClose();
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(article) => article.id === newArticleId
|
||||
@@ -127,38 +142,13 @@ export const RightWidgetTab = observer(
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
setPreviewSection(newIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating new article:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectExisting = () => {
|
||||
setIsSelectModalOpen(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCloseSelectModal = () => {
|
||||
setIsSelectModalOpen(false);
|
||||
};
|
||||
|
||||
const handleArticleSelect = async (id: number) => {
|
||||
try {
|
||||
const linkedArticleId = await linkArticle(id);
|
||||
handleCloseSelectModal();
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(article) => article.id === linkedArticleId
|
||||
);
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error linking article:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelected = async (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
@@ -220,8 +210,15 @@ export const RightWidgetTab = observer(
|
||||
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
|
||||
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
|
||||
<Box
|
||||
onClick={() => setType("media")}
|
||||
className="w-full bg-green-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
|
||||
onClick={() => {
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}}
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
|
||||
type === "media"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Typography>Предпросмотр медиа</Typography>
|
||||
</Box>
|
||||
@@ -246,14 +243,17 @@ export const RightWidgetTab = observer(
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${
|
||||
className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
|
||||
snapshot.isDragging
|
||||
? "shadow-lg"
|
||||
: ""
|
||||
? "shadow-lg bg-gray-200"
|
||||
: activeArticleIndex === index && type === "article"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleSelectArticle(index);
|
||||
setType("article");
|
||||
setPreviewSection(index);
|
||||
}}
|
||||
>
|
||||
<Box {...provided.dragHandleProps}>
|
||||
@@ -276,27 +276,10 @@ export const RightWidgetTab = observer(
|
||||
</Box>
|
||||
<button
|
||||
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center"
|
||||
onClick={handleClick}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
"aria-labelledby": "basic-button",
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem onClick={handleCreateNew}>
|
||||
<Typography>Создать новую</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleSelectExisting}>
|
||||
<Typography>Выбрать существующую статью</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{type === "article" && (
|
||||
@@ -458,6 +441,7 @@ export const RightWidgetTab = observer(
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, width: "550px", display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{type === "media" && (
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
type="number"
|
||||
@@ -491,6 +475,29 @@ export const RightWidgetTab = observer(
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
label="Полное название (поддерживает перенос ↵)"
|
||||
multiline
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
size="small"
|
||||
value={sight[language].name}
|
||||
onChange={(e) => updateSightInfo(language, { name: e.target.value })}
|
||||
inputRef={shortNameRef}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={insertNewline}
|
||||
title="Вставить перенос строки"
|
||||
sx={{ minWidth: 40, height: 40, fontSize: 18, p: 0, flexShrink: 0 }}
|
||||
>
|
||||
↵
|
||||
</Button>
|
||||
</Stack>
|
||||
<SightFramePreview
|
||||
sightName={sight[language].name}
|
||||
previewMedia={previewMedia}
|
||||
@@ -498,8 +505,19 @@ export const RightWidgetTab = observer(
|
||||
onArticleSelect={(idx) => {
|
||||
handleSelectArticle(idx);
|
||||
setType("article");
|
||||
setPreviewSection(idx);
|
||||
}}
|
||||
previewFontSize={sight.common.preview_font_size}
|
||||
selectedSection={previewSection}
|
||||
onSectionChange={(section) => {
|
||||
setPreviewSection(section);
|
||||
if (section === -1) {
|
||||
setType("media");
|
||||
} else {
|
||||
handleSelectArticle(section);
|
||||
setType("article");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -557,12 +575,6 @@ export const RightWidgetTab = observer(
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectArticleModal
|
||||
open={isSelectModalOpen}
|
||||
onClose={handleCloseSelectModal}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
||||
/>
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaModalOpen}
|
||||
onClose={() => setIsSelectMediaModalOpen(false)}
|
||||
|
||||
@@ -37,7 +37,8 @@ export const TestingModeBanner = observer(() => {
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С АДМИН-ПАНЕЛЬЮ
|
||||
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С ПАНЕЛЬЮ
|
||||
АДМИНИСТРИРОВАНИЯ
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user