feat: update demo page + add city_id for media and articles

This commit is contained in:
2026-04-28 10:57:42 +03:00
parent 60c6840db4
commit 94f512e0e4
27 changed files with 499 additions and 222 deletions

View File

@@ -56,7 +56,7 @@ class ApiStore {
carrier: GetCarrierResponse | null = null; carrier: GetCarrierResponse | null = null;
city: GetCityResponse | null = null; city: GetCityResponse | null = null;
private positionIndex = 0; positionIndex = 0;
private positionInterval: ReturnType<typeof setInterval> | null = null; private positionInterval: ReturnType<typeof setInterval> | null = null;
simulationSpeed = 1; simulationSpeed = 1;

View File

@@ -797,7 +797,9 @@ export const WebGLMap = observer(() => {
const textBlockPositionX = rx + labelOffsetX; const textBlockPositionX = rx + labelOffsetX;
const textBlockPositionY = ry + labelOffsetY; 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; const textWidthInMapCoords = approximateTextWidth / scale;
let anchorXOffset = 0; let anchorXOffset = 0;
@@ -827,8 +829,8 @@ export const WebGLMap = observer(() => {
result.push({ result.push({
x: sx, x: sx,
y: sy, y: sy,
name: st.name, name: st.name.replace(/\\n/g, '\n'),
sub, sub: sub ? sub.replace(/\\n/g, '\n') : sub,
anchorX: anchorX, anchorX: anchorX,
anchorY: anchorY, anchorY: anchorY,
distance: distanceInPixels, distance: distanceInPixels,
@@ -878,6 +880,32 @@ export const WebGLMap = observer(() => {
rotationAngle, 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(() => { const sightPoints = useMemo(() => {
if (!sightData || !routeData) return new Float32Array(); if (!sightData || !routeData) return new Float32Array();
const centerLat = routeData.center_latitude; const centerLat = routeData.center_latitude;
@@ -1095,6 +1123,8 @@ export const WebGLMap = observer(() => {
}; };
}, []); }, []);
const prevPositionIndexRef = useRef<number>(-1);
useEffect(() => { useEffect(() => {
const centerLat = routeData?.center_latitude; const centerLat = routeData?.center_latitude;
const centerLon = routeData?.center_longitude; const centerLon = routeData?.center_longitude;
@@ -1112,7 +1142,14 @@ export const WebGLMap = observer(() => {
const rx = x * cos - y * sin; const rx = x * cos - y * sin;
const ry = x * sin + y * cos; 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); setYellowDotImmediate(rx, ry);
} else { } else {
animateYellowDotTo(rx, ry); animateYellowDotTo(rx, ry);
@@ -1164,6 +1201,7 @@ export const WebGLMap = observer(() => {
gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0);
let tramSegIndex = getCurrentSegIndex(); let tramSegIndex = getCurrentSegIndex();
const simulationDirection = apiStore.simulationDirection;
const dpr = Math.max(1, window.devicePixelRatio || 1); const dpr = Math.max(1, window.devicePixelRatio || 1);
const desiredRouteWidthCss = 7; const desiredRouteWidthCss = 7;
@@ -1261,23 +1299,69 @@ export const WebGLMap = observer(() => {
const r1 = ((PATH_COLOR >> 16) & 0xff) / 255; const r1 = ((PATH_COLOR >> 16) & 0xff) / 255;
const g1 = ((PATH_COLOR >> 8) & 0xff) / 255; const g1 = ((PATH_COLOR >> 8) & 0xff) / 255;
const b1 = (PATH_COLOR & 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; const animatedPos = animatedYellowDotPosition;
if ( if (
tramSegIndex >= 0 &&
animatedPos && animatedPos &&
animatedPos.x !== undefined && animatedPos.x !== undefined &&
animatedPos.y !== undefined animatedPos.y !== undefined
) { ) {
if (simulationDirection === 1) {
// Вперёд: закрашено от начала до трамвая
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
const passedPoints: number[] = []; const passedPoints: number[] = [];
for (let i = 0; i <= tramSegIndex; i++) { for (let i = 0; i <= tramSegIndex; i++) {
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
} }
passedPoints.push(animatedPos.x, animatedPos.y); 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) { if (passedPoints.length >= 4) {
const thickLineVertices = generateThickLine( const thickLineVertices = generateThickLine(
new Float32Array(passedPoints), new Float32Array(passedPoints),
@@ -1287,30 +1371,16 @@ export const WebGLMap = observer(() => {
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
} }
} }
} } else {
// Позиция трамвая неизвестна — рисуем весь маршрут серым
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
const allPoints: number[] = [];
const animatedPos = animatedYellowDotPosition; for (let i = 0; i < vertexCount; i++) {
if ( allPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
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]);
} }
if (allPoints.length >= 4) {
if (unpassedPoints.length >= 4) {
const thickLineVertices = generateThickLine( const thickLineVertices = generateThickLine(
new Float32Array(unpassedPoints), new Float32Array(allPoints),
lineWidth, lineWidth,
); );
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
@@ -1345,92 +1415,32 @@ export const WebGLMap = observer(() => {
gl.uniform1f(u_pointSize, pointInnerSizePx); gl.uniform1f(u_pointSize, pointInnerSizePx);
let currentStationIndexInOrdered = -1; if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
if (currentStationId && orderedRouteStations) { const passedPts1: number[] = [];
currentStationIndexInOrdered = orderedRouteStations.findIndex( const unpassedPts1: number[] = [];
(station: any) => String(station.id) === String(currentStationId), 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 (passedPts1.length > 0) {
if ( gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1);
currentStationIndexInOrdered >= 0 && gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW);
orderedRouteStations && gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
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 (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);
if (passedStations.length > 0) { gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW);
const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255; gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
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);
} }
} else { } else {
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
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, stationPoints, gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
} }
@@ -1501,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 passedStationIds = new Set<string>();
const unpassedStationIds = new Set<string>(); const unpassedStationIds = new Set<string>();
if (currentStationIndexInOrdered >= 0 && orderedRouteStations) { if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
for (let i = 0; i < currentStationIndexInOrdered; i++) { for (let i = 0; i < orderedRouteStations.length; i++) {
const station = orderedRouteStations[i]; const station = (orderedRouteStations as any[])[i];
if (station) { const seg = orderedStationSegs[i] ?? -1;
passedStationIds.add(String(station.id)); 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));
for (
let i = currentStationIndexInOrdered;
i < orderedRouteStations.length;
i++
) {
const station = orderedRouteStations[i];
if (station) {
unpassedStationIds.add(String(station.id));
}
} }
} else { } else {
if (orderedRouteStations) { if (orderedRouteStations) {
orderedRouteStations.forEach((station: any) => { (orderedRouteStations as any[]).forEach((station) => {
unpassedStationIds.add(String(station.id)); unpassedStationIds.add(String(station.id));
}); });
} }
@@ -1668,12 +1662,12 @@ export const WebGLMap = observer(() => {
const cos = Math.cos(rotationAngle); const cos = Math.cos(rotationAngle);
const sin = Math.sin(rotationAngle); const sin = Math.sin(rotationAngle);
const startStationData = stationData.find( const startStationData = orderedRouteStations?.[0]
(station: any) => station.id.toString() === apiStore.context?.startStopId, ? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id))
); : stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId);
const endStationData = stationData.find( const endStationData = orderedRouteStations?.length
(station: any) => station.id.toString() === apiStore.context?.endStopId, ? 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[] = []; const terminalStations: number[] = [];
@@ -1773,7 +1767,7 @@ export const WebGLMap = observer(() => {
} }
return best; return best;
})(); })();
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
})() })()
: false; : false;
@@ -1806,7 +1800,7 @@ export const WebGLMap = observer(() => {
} }
return best; return best;
})(); })();
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
})() })()
: false; : false;
@@ -1832,20 +1826,11 @@ export const WebGLMap = observer(() => {
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
if (startStationData && endStationData) { 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); gl.drawArrays(gl.POINTS, 0, 1);
gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0);
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.drawArrays(gl.POINTS, 1, 1); gl.drawArrays(gl.POINTS, 1, 1);
} else { } else {
const isStartStation = startStationData !== undefined; const isStartStation = startStationData !== undefined;
@@ -1885,6 +1870,8 @@ export const WebGLMap = observer(() => {
nearestStationId, nearestStationId,
currentStationId, currentStationId,
orderedRouteStations, orderedRouteStations,
orderedStationSegs,
apiStore.simulationDirection,
]); ]);
useEffect(() => { useEffect(() => {
@@ -2328,7 +2315,7 @@ export const WebGLMap = observer(() => {
fontSize: primaryFontSize, fontSize: primaryFontSize,
textShadow: "0 0 4px rgba(0,0,0,0.6)", textShadow: "0 0 4px rgba(0,0,0,0.6)",
pointerEvents: "none", pointerEvents: "none",
whiteSpace: "nowrap", whiteSpace: "pre-line",
}} }}
> >
{l.name} {l.name}
@@ -2344,7 +2331,7 @@ export const WebGLMap = observer(() => {
lineHeight: secondaryLineHeight, lineHeight: secondaryLineHeight,
color: "#CBCBCB", color: "#CBCBCB",
textShadow: "0 0 3px rgba(0,0,0,0.4)", textShadow: "0 0 3px rgba(0,0,0,0.4)",
whiteSpace: "nowrap", whiteSpace: "pre-line",
...secondaryPositionStyle, ...secondaryPositionStyle,
pointerEvents: "none", pointerEvents: "none",
}} }}

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, articlesStore, languageStore, SearchInput } from "@shared"; import { authStore, articlesStore, languageStore, SearchInput, selectedCityStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react"; import { Trash2, Eye, Minus } from "lucide-react";
@@ -75,14 +75,16 @@ export const ArticleListPage = observer(() => {
const rows = useMemo(() => { const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const cityId = selectedCityStore.selectedCityId;
return articleList[language].data return articleList[language].data
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query)) .filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
.filter((article) => !cityId || article.city_id === cityId)
.map((article) => ({ .map((article) => ({
id: article.id, id: article.id,
heading: article.heading, heading: article.heading,
body: article.body, body: article.body,
})); }));
}, [articleList[language].data, searchQuery]); }, [articleList[language].data, searchQuery, selectedCityStore.selectedCityId]);
return ( return (
<> <>

View File

@@ -28,7 +28,8 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { language } = languageStore; const { language } = languageStore;
const { createCityData, setCreateCityData, setCreateCityWeatherCode } = cityStore; const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
cityStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -72,7 +73,7 @@ export const CityCreatePage = observer(() => {
createCityData[language].name, createCityData[language].name,
createCityData.country_code, createCityData.country_code,
media.id, media.id,
language language,
); );
}; };
@@ -111,7 +112,7 @@ export const CityCreatePage = observer(() => {
e.target.value, e.target.value,
createCityData.country_code, createCityData.country_code,
createCityData.arms, createCityData.arms,
language language,
) )
} }
/> />
@@ -127,7 +128,7 @@ export const CityCreatePage = observer(() => {
createCityData[language].name, createCityData[language].name,
e.target.value, e.target.value,
createCityData.arms, createCityData.arms,
language language,
); );
}} }}
> >
@@ -144,7 +145,6 @@ export const CityCreatePage = observer(() => {
label="Код города для погоды" label="Код города для погоды"
type="number" type="number"
value={createCityData.weather_city_code ?? 0} value={createCityData.weather_city_code ?? 0}
helperText="Числовой код города в источнике погоды (Кранштат)"
onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))} onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))}
/> />
@@ -162,7 +162,7 @@ export const CityCreatePage = observer(() => {
createCityData[language].name, createCityData[language].name,
createCityData.country_code, createCityData.country_code,
"", "",
language language,
); );
setActiveMenuType(null); setActiveMenuType(null);
}} }}

View File

@@ -40,7 +40,13 @@ export const CityEditPage = observer(() => {
>(null); >(null);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData, setEditCityWeatherCode } = cityStore; const {
editCityData,
editCity,
getCity,
setEditCityData,
setEditCityWeatherCode,
} = cityStore;
const { getCountries } = countryStore; const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
@@ -108,7 +114,7 @@ export const CityEditPage = observer(() => {
: null; : null;
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms) const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
? null ? null
: selectedMedia?.id ?? editCityData.arms; : (selectedMedia?.id ?? editCityData.arms);
if (isLoadingData) { if (isLoadingData) {
return ( return (
@@ -185,7 +191,6 @@ export const CityEditPage = observer(() => {
label="Код города для погоды" label="Код города для погоды"
type="number" type="number"
value={editCityData.weather_city_code ?? 0} value={editCityData.weather_city_code ?? 0}
helperText="Числовой код города в источнике погоды (Кранштат)"
onChange={(e) => setEditCityWeatherCode(Number(e.target.value))} onChange={(e) => setEditCityWeatherCode(Number(e.target.value))}
/> />

View File

@@ -12,7 +12,7 @@ import {
CreateRightTab, CreateRightTab,
LeaveAgree, LeaveAgree,
} from "@widgets"; } from "@widgets";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
function a11yProps(index: number) { function a11yProps(index: number) {

View File

@@ -7,7 +7,7 @@ import {
FormControl, FormControl,
InputLabel, InputLabel,
} from "@mui/material"; } 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 { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -23,7 +23,7 @@ export const MediaCreatePage = observer(() => {
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await mediaStore.createMedia(name, type); await mediaStore.createMedia(name, type, selectedCityStore.selectedCityId);
toast.success("Медиа успешно создано"); toast.success("Медиа успешно создано");
navigate("/media"); navigate("/media");
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, 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 { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react"; import { Eye, Trash2, Minus } from "lucide-react";
@@ -98,14 +98,16 @@ export const MediaListPage = observer(() => {
const rows = useMemo(() => { const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const cityId = selectedCityStore.selectedCityId;
return media return media
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query)) .filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
.filter((item) => !cityId || item.city_id === cityId)
.map((item) => ({ .map((item) => ({
id: item.id, id: item.id,
media_name: item.media_name, media_name: item.media_name,
media_type: item.media_type, media_type: item.media_type,
})); }));
}, [media, searchQuery]); }, [media, searchQuery, selectedCityStore.selectedCityId]);
return ( return (
<> <>

View File

@@ -286,9 +286,9 @@ export const RouteCreatePage = observer(() => {
newRoute.governor_appeal = governor_appeal; newRoute.governor_appeal = governor_appeal;
} }
await routeStore.createRoute(newRoute); const newId = await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(-1); navigate(`/route/${newId}/edit`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Произошла ошибка при создании маршрута"); toast.error("Произошла ошибка при создании маршрута");

View File

@@ -141,6 +141,7 @@ export function RightSidebar() {
bgcolor="primary.main" bgcolor="primary.main"
border="1px solid #e0e0e0" border="1px solid #e0e0e0"
borderRadius={2} borderRadius={2}
zIndex={2}
> >
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center"> <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 { Close } from "@mui/icons-material";
import { Landmark } from "lucide-react"; import { Landmark } from "lucide-react";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { RouteWidget } from "./webgl-prototype/RouteWidget";
export function Widgets() { export function Widgets() {
const { selectedSight, setSelectedSight } = useMapData(); const { selectedSight, setSelectedSight } = useMapData();
@@ -13,22 +14,11 @@ export function Widgets() {
position="absolute" position="absolute"
top={32} top={32}
left={32} left={32}
zIndex={2}
sx={{ pointerEvents: "none" }} sx={{ pointerEvents: "none" }}
> >
<Stack {/* Виджет маршрута */}
bgcolor="primary.main" <RouteWidget />
width={361}
height={96}
p={2}
m={2}
borderRadius={2}
alignItems="center"
justifyContent="center"
>
<Typography variant="h6" sx={{ color: "#fff" }}>
Остановка
</Typography>
</Stack>
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */} {/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
<Stack <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

@@ -68,9 +68,9 @@ export const StationCreatePage = observer(() => {
const executeCreate = async () => { const executeCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await createStation(); const data = await createStation();
toast.success("Остановка успешно создана"); toast.success("Остановка успешно создана");
navigate("/station"); navigate(`/station/${data.id}/edit`);
} catch (error) { } catch (error) {
console.error("Error creating station:", error); console.error("Error creating station:", error);
toast.error("Ошибка при создании остановки"); toast.error("Ошибка при создании остановки");

View File

@@ -326,6 +326,9 @@ export const UserEditPage = observer(() => {
if (!next.includes("snapshot_create")) { if (!next.includes("snapshot_create")) {
next.push("snapshot_create"); next.push("snapshot_create");
} }
if (!next.includes("devices_maintenance_rw")) {
next.push("devices_maintenance_rw");
}
next.push("admin"); next.push("admin");
return next; 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 }}>Чтение/Запись</TableCell> <TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}> <TableCell align="center" sx={{ fontWeight: 600 }}>
Создание (snapshot_create) Доп. права
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -386,6 +389,8 @@ export const UserEditPage = observer(() => {
}); });
}; };
const isDevicesResource = key === "devices";
const handleSnapshotCreateChange = (checked: boolean) => { const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) { if (!isSnapshotResource) {
return; 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 ( return (
<TableRow key={key} hover> <TableRow key={key} hover>
<TableCell>{label}</TableCell> <TableCell>{label}</TableCell>
@@ -447,6 +459,14 @@ export const UserEditPage = observer(() => {
handleSnapshotCreateChange(e.target.checked) handleSnapshotCreateChange(e.target.checked)
} }
size="small" 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"> <Typography variant="body2" color="text.secondary">

View File

@@ -5,6 +5,7 @@ import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
Language, Language,
selectedCityStore,
} from "@shared"; } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -232,6 +233,7 @@ export const ArticleSelectOrCreateDialog = observer(
return; return;
} }
const cityId = selectedCityStore.selectedCityId;
const response = await authInstance.post("/article", { const response = await authInstance.post("/article", {
translations: { translations: {
heading: { heading: {
@@ -245,6 +247,7 @@ export const ArticleSelectOrCreateDialog = observer(
zh: newArticleData.zh.body || "Новый текст (ZH)", zh: newArticleData.zh.body || "Новый текст (ZH)",
}, },
}, },
...(cityId ? { city_id: cityId } : {}),
}); });
const { id } = response.data; const { id } = response.data;
@@ -519,9 +522,12 @@ export const ArticleSelectOrCreateDialog = observer(
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}; };
const filteredArticles = articles[modalLanguage].filter((article) => const cityId = selectedCityStore.selectedCityId;
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase()) 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 [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null); 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 { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -116,12 +116,16 @@ export const SelectArticleModal = observer(
} }
}; };
const cityId = selectedCityStore.selectedCityId;
const filteredArticles = articles[languageStore.language].filter( 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 ( return (
<Dialog <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 { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -92,11 +92,17 @@ export const SelectMediaDialog = observer(
}; };
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener }, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
const cityId = selectedCityStore.selectedCityId;
let filteredMedia = media let filteredMedia = media
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision .filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id))
.filter((mediaItem) => .filter((mediaItem) =>
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase()) mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
); )
.filter((mediaItem) => {
if (!cityId) return true;
return mediaItem.city_id === cityId;
});
if (mediaType) { if (mediaType) {
filteredMedia = filteredMedia.filter( filteredMedia = filteredMedia.filter(

View File

@@ -4,9 +4,12 @@ import {
editSightStore, editSightStore,
generateDefaultMediaName, generateDefaultMediaName,
clearBlobAndGLTFCache, clearBlobAndGLTFCache,
authStore,
snapshotStore,
} from "@shared"; } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { toast } from "react-toastify";
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@@ -247,12 +250,16 @@ export const UploadMediaDialog = observer(
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const uploadStartTime = Date.now();
try { try {
const effectiveMediaType = hardcodeType
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
: mediaType;
const media = await uploadMedia( const media = await uploadMedia(
mediaFilename, mediaFilename,
hardcodeType effectiveMediaType,
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
: mediaType,
mediaFile, mediaFile,
mediaName mediaName
); );
@@ -263,6 +270,40 @@ export const UploadMediaDialog = observer(
await afterUpload(media); 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); setSuccess(true);
setTimeout(() => { setTimeout(() => {

View File

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

View File

@@ -4,6 +4,7 @@ import {
authInstance, authInstance,
languageInstance, languageInstance,
mediaStore, mediaStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
@@ -129,6 +130,7 @@ class CreateSightStore {
zh: articleZhData.body, zh: articleZhData.body,
}, },
}, },
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
}); });
const { id } = articleRes.data; const { id } = articleRes.data;
@@ -346,6 +348,7 @@ class CreateSightStore {
const response = await languageInstance("ru").post("/article", { const response = await languageInstance("ru").post("/article", {
heading: hasAnyName ? ruName : "", heading: hasAnyName ? ruName : "",
body: "", body: "",
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
}); });
const newLeftArticleId = response.data.id; const newLeftArticleId = response.data.id;
@@ -449,6 +452,7 @@ class CreateSightStore {
const res = await languageInstance("ru").post("/article", { const res = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading, heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body, body: this.sight.ru.left.body,
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
}); });
finalLeftArticleId = res.data.id; finalLeftArticleId = res.data.id;
await languageInstance("en").patch(`/article/${finalLeftArticleId}`, { await languageInstance("en").patch(`/article/${finalLeftArticleId}`, {
@@ -567,6 +571,9 @@ class CreateSightStore {
formData.append("filename", filename); formData.append("filename", filename);
if (media_name) formData.append("media_name", media_name); if (media_name) formData.append("media_name", media_name);
formData.append("type", type.toString()); formData.append("type", type.toString());
if (selectedCityStore.selectedCityId) {
formData.append("city_id", selectedCityStore.selectedCityId.toString());
}
try { try {
const response = await authInstance.post(`/media`, formData); const response = await authInstance.post(`/media`, formData);

View File

@@ -4,6 +4,7 @@ import {
Language, Language,
languageInstance, languageInstance,
mediaStore, mediaStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
@@ -270,6 +271,7 @@ class EditSightStore {
const response = await languageInstance("ru").post(`/article`, { const response = await languageInstance("ru").post(`/article`, {
heading: this.sight.ru.left.heading, heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body, body: this.sight.ru.left.body,
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
}); });
createdLeftArticleId = response.data.id; createdLeftArticleId = response.data.id;
await languageInstance("en").patch(`/article/${createdLeftArticleId}`, { await languageInstance("en").patch(`/article/${createdLeftArticleId}`, {
@@ -412,6 +414,7 @@ class EditSightStore {
const response = await languageInstance("ru").post(`/article`, { const response = await languageInstance("ru").post(`/article`, {
heading: hasAnyName ? ruName : "", heading: hasAnyName ? ruName : "",
body: "", body: "",
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
}); });
this.sight.common.left_article = response.data.id; this.sight.common.left_article = response.data.id;
@@ -510,6 +513,9 @@ class EditSightStore {
formData.append("media_name", media_name); formData.append("media_name", media_name);
} }
formData.append("type", type.toString()); formData.append("type", type.toString());
if (selectedCityStore.selectedCityId) {
formData.append("city_id", selectedCityStore.selectedCityId.toString());
}
const response = await authInstance.post(`/media`, formData); const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null; this.fileToUpload = null;
@@ -652,6 +658,7 @@ class EditSightStore {
zh: articleZhData.body, zh: articleZhData.body,
}, },
}, },
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
}); });
const { id } = articleId.data; const { id } = articleId.data;

View File

@@ -6,6 +6,7 @@ export type Media = {
filename: string; filename: string;
media_name: string; media_name: string;
media_type: number; media_type: number;
city_id?: number | null;
}; };
class MediaStore { class MediaStore {
@@ -75,10 +76,11 @@ class MediaStore {
return response.data; return response.data;
}; };
createMedia = async (name: string, type: string) => { createMedia = async (name: string, type: string, cityId?: number | null) => {
const response = await authInstance.post("/media", { const response = await authInstance.post("/media", {
media_name: name, media_name: name,
media_type: type, media_type: type,
...(cityId ? { city_id: cityId } : {}),
}); });
runInAction(() => { runInAction(() => {
this.media.push(response.data); 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 response = await authInstance.post("/route", route);
const id = response.data.id; const id = response.data.id;
@@ -61,6 +61,8 @@ class RouteStore {
this.route[id] = { ...route, id }; this.route[id] = { ...route, id };
this.routes.data = [...this.routes.data, { ...route, id }]; this.routes.data = [...this.routes.data, { ...route, id }];
}); });
return id;
}; };
deleteRoute = async (id: number) => { deleteRoute = async (id: number) => {

View File

@@ -468,7 +468,7 @@ class StationsStore {
this.stationLists[language].data.push(response.data); 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( for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language (lang) => lang !== language

View File

@@ -129,6 +129,8 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
export const DevicesTable = observer(() => { export const DevicesTable = observer(() => {
const canWriteDevices = authStore.canWrite("devices"); const canWriteDevices = authStore.canWrite("devices");
const canMaintenanceDevices = authStore.hasRole("devices_maintenance_rw");
const isMaintenanceOnly = canMaintenanceDevices && !canWriteDevices;
const { const {
getDevices, getDevices,
setSelectedDevice, setSelectedDevice,
@@ -706,9 +708,24 @@ export const DevicesTable = observer(() => {
demoConfirmSubmitting, demoConfirmSubmitting,
routes, routes,
canWriteDevices, 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(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
@@ -900,7 +917,7 @@ export const DevicesTable = observer(() => {
<Box sx={{ p: 0 }}> <Box sx={{ p: 0 }}>
<DataGrid <DataGrid
rows={groupRows} rows={groupRows}
columns={columns} columns={visibleColumns}
checkboxSelection={canWriteDevices} checkboxSelection={canWriteDevices}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick

File diff suppressed because one or more lines are too long