Compare commits

4 Commits

66 changed files with 1352 additions and 968 deletions

4
src/client/src/App.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
import React from "react";
declare const App: React.FC;
export default App;

View File

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

View File

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

View File

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

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

View File

@@ -1,4 +1,4 @@
import React from "react";
import "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import "./ReactMarkdown.css";

View File

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

View File

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

View File

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

View File

@@ -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 хранилищу

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] = []
) {
// Анимация начинается с текущего зума и плавно переходит к максимальному зуму
// для плавного приближения к желтой точке при слежении

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, 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}

View File

@@ -168,6 +168,7 @@ export const CarrierListPage = observer(() => {
columns={columns}
checkboxSelection={canWriteCarriers}
disableRowSelectionExcludeModel
disableRowSelectionOnClick
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}

View File

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

View File

@@ -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="Герб города"

View File

@@ -162,6 +162,7 @@ export const CityListPage = observer(() => {
columns={columns}
checkboxSelection={canWriteCities}
disableRowSelectionExcludeModel
disableRowSelectionOnClick
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}

View File

@@ -115,6 +115,7 @@ export const CountryListPage = observer(() => {
columns={columns}
checkboxSelection={canWriteCountries}
disableRowSelectionExcludeModel
disableRowSelectionOnClick
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, 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}

View File

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

View File

@@ -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("Произошла ошибка при создании маршрута");

View File

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

View File

@@ -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">
Настройка маршрута

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("Ошибка при создании остановки");

View File

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

View File

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

View File

@@ -153,6 +153,7 @@ export const UserListPage = observer(() => {
columns={columns}
checkboxSelection={canWriteUsers}
disableRowSelectionExcludeModel
disableRowSelectionOnClick
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}

View File

@@ -188,6 +188,7 @@ export const VehicleListPage = observer(() => {
columns={columns}
checkboxSelection={canWriteVehicles}
disableRowSelectionExcludeModel
disableRowSelectionOnClick
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}

View File

@@ -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: "Выйти",

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export type Article = {
heading: string;
body: string;
service_name: string;
city_id?: number | null;
ru?: {
heading: string;
body: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,8 @@ export const TestingModeBanner = observer(() => {
pointerEvents: "none",
}}
>
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С АДМИН-ПАНЕЛЬЮ
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С ПАНЕЛЬЮ
АДМИНИСТРИРОВАНИЯ
</div>
);
});

File diff suppressed because one or more lines are too long