Compare commits

5 Commits

31 changed files with 528 additions and 171 deletions

14
.env
View File

@@ -1,8 +1,8 @@
VITE_API_URL='https://wn.st.unprism.ru'
VITE_REACT_APP ='https://wn.st.unprism.ru/'
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
VITE_NEED_AUTH='true'
# VITE_API_URL='https://wn.krbl.ru'
# VITE_REACT_APP ='https://wn.krbl.ru/'
# VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
# # VITE_API_URL='https://wn.st.unprism.ru'
# # VITE_REACT_APP ='https://wn.st.unprism.ru/'
# # VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
# VITE_NEED_AUTH='true'
VITE_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.krbl.ru/'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
VITE_NEED_AUTH='true'

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, articlesStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -22,6 +22,7 @@ export const ArticleListPage = observer(() => {
pageSize: 50,
});
const canWriteArticles = authStore.canWrite("sights");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchArticles = async () => {
@@ -72,11 +73,16 @@ export const ArticleListPage = observer(() => {
},
];
const rows = articleList[language].data.map((article) => ({
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return articleList[language].data
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,
}));
}, [articleList[language].data, searchQuery]);
return (
<>
@@ -99,6 +105,8 @@ export const ArticleListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div className="w-full">
<DataGrid
rows={rows}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -22,6 +22,7 @@ export const CarrierListPage = observer(() => {
});
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -119,9 +120,15 @@ export const CarrierListPage = observer(() => {
const canWriteCarriers = authStore.canWrite("carriers");
const rows = carriers[language].data
?.filter((carrier) =>
!allowedCityIds || allowedCityIds.includes(carrier.city_id),
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (carriers[language].data ?? [])
.filter((carrier) => !allowedCityIds || allowedCityIds.includes(carrier.city_id))
.filter(
(carrier) =>
!query ||
(carrier.full_name ?? "").toLowerCase().includes(query) ||
(carrier.short_name ?? "").toLowerCase().includes(query)
)
.map((carrier) => ({
id: carrier.id,
@@ -129,6 +136,7 @@ export const CarrierListPage = observer(() => {
short_name: carrier.short_name,
city_id: carrier.city_id,
}));
}, [carriers[language].data, searchQuery, allowedCityIds]);
return (
<>
@@ -153,6 +161,8 @@ export const CarrierListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, cityStore, countryStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -24,6 +24,7 @@ export const CityListPage = observer(() => {
});
const { language } = languageStore;
const canWriteCities = authStore.canWrite("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -57,6 +58,18 @@ export const CityListPage = observer(() => {
setRows(newRows2 || []);
}, [cities, countryStore.countries, language, isLoading]);
const filteredRows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return rows;
return rows.filter((row) => {
const cityName = (row.name ?? "").toLowerCase();
const countryName = (
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
).toLowerCase();
return cityName.includes(query) || countryName.includes(query);
});
}, [rows, searchQuery, countryStore.countries, language]);
const columns: GridColDef[] = [
{
field: "country",
@@ -142,8 +155,10 @@ export const CityListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
rows={filteredRows}
columns={columns}
checkboxSelection={canWriteCities}
disableRowSelectionExcludeModel

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, countryStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Minus } from "lucide-react";
@@ -22,6 +22,7 @@ export const CountryListPage = observer(() => {
});
const { language } = languageStore;
const canWriteCountries = authStore.canWrite("countries");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchCountries = async () => {
@@ -72,11 +73,16 @@ export const CountryListPage = observer(() => {
}] : []),
];
const rows = countries[language]?.data.map((country) => ({
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (countries[language]?.data ?? [])
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query))
.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
}));
}, [countries[language]?.data, searchQuery]);
return (
<>
@@ -102,8 +108,10 @@ export const CountryListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows || []}
rows={rows}
columns={columns}
checkboxSelection={canWriteCountries}
disableRowSelectionExcludeModel

View File

@@ -244,9 +244,9 @@ class MapStore {
const sorted = [...features];
switch (sortType) {
case "name_asc":
return sorted.sort((a, b) => a.name.localeCompare(b.name));
return sorted.sort((a, b) => a.name.trim().localeCompare(b.name.trim()));
case "name_desc":
return sorted.sort((a, b) => b.name.localeCompare(a.name));
return sorted.sort((a, b) => b.name.trim().localeCompare(a.name.trim()));
case "created_asc":
return sorted.sort((a, b) => {
if (
@@ -379,7 +379,7 @@ class MapStore {
}));
this.routes = this.routes.sort((a, b) =>
a.route_number.localeCompare(b.route_number),
a.route_number.trim().localeCompare(b.route_number.trim()),
);
await this.preloadRouteStations(routesIds);
@@ -2545,14 +2545,14 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
switch (sortType) {
case "name_asc":
return sorted.sort((a, b) =>
((a.get("name") as string) || "").localeCompare(
(b.get("name") as string) || "",
((a.get("name") as string) || "").trim().localeCompare(
((b.get("name") as string) || "").trim(),
),
);
case "name_desc":
return sorted.sort((a, b) =>
((b.get("name") as string) || "").localeCompare(
(a.get("name") as string) || "",
((b.get("name") as string) || "").trim().localeCompare(
((a.get("name") as string) || "").trim(),
),
);
case "created_asc":

View File

@@ -69,7 +69,7 @@ class MapStore {
path: route.path,
}));
this.routes = mappedRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
a.route_number.trim().localeCompare(b.route_number.trim())
);
};

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -22,6 +22,7 @@ export const MediaListPage = observer(() => {
});
const { language } = languageStore;
const canWriteMedia = authStore.canWrite("sights");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchMedia = async () => {
@@ -95,15 +96,22 @@ export const MediaListPage = observer(() => {
},
];
const rows = media.map((media) => ({
id: media.id,
media_name: media.media_name,
media_type: media.media_type,
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return media
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
.map((item) => ({
id: item.id,
media_name: item.media_name,
media_type: item.media_type,
}));
}, [media, searchQuery]);
return (
<>
<div className="w-full">
<SearchInput value={searchQuery} onChange={setSearchQuery} />
{canWriteMedia && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button

View File

@@ -267,9 +267,9 @@ export const RouteCreatePage = observer(() => {
language as keyof typeof carrierStore.carriers
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
carrier_id,
route_number: routeNumber,
route_sys_number: govRouteNumber,
route_name: routeName,
route_number: routeNumber.trim(),
route_sys_number: govRouteNumber.trim(),
route_name: routeName.trim(),
route_direction,
scale_min: scale_min !== null ? scale_min : 0,
scale_max: scale_max !== null ? scale_max : 0,

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, carrierStore, languageStore, routeStore, selectedCityStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -28,6 +28,7 @@ export const RouteListPage = observer(() => {
authStore.canWrite("sights") &&
authStore.canWrite("routes");
const canShowActionsColumn = canWriteRoutes || canShowRoutePreview;
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -146,13 +147,33 @@ export const RouteListPage = observer(() => {
}] : []),
];
const rows = routes.data.map((route) => ({
const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore;
const query = searchQuery.trim().toLowerCase();
let filtered = routes.data;
if (selectedCityId) {
const cityCarrierIds = new Set(
carriers["ru"].data
.filter((c) => c.city_id === selectedCityId)
.map((c) => c.id)
);
filtered = filtered.filter((route) => cityCarrierIds.has(route.carrier_id));
}
return filtered
.filter(
(route) =>
!query ||
(route.route_name ?? "").toLowerCase().includes(query) ||
String(route.route_number ?? "").toLowerCase().includes(query)
)
.map((route) => ({
id: route.id,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
}));
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
return (
<>
@@ -178,6 +199,8 @@ export const RouteListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}

View File

@@ -44,6 +44,7 @@ const MapDataContext = createContext<{
) => void;
setSightIconSize: (sightId: number, size: number) => void;
setFontSize: (size: number) => void;
setRouteIconSize: (size: number) => void;
saveChanges: () => void;
}>({
originalRouteData: undefined,
@@ -67,6 +68,7 @@ const MapDataContext = createContext<{
setSightCoordinates: () => {},
setSightIconSize: () => {},
setFontSize: () => {},
setRouteIconSize: () => {},
saveChanges: () => {},
});
@@ -180,6 +182,16 @@ export const MapDataProvider = observer(
});
}
function setRouteIconSize(size: number) {
const clamped = Math.max(1, Math.min(300, size));
setRouteChanges((prev) => {
if (prev.icon_size === clamped) {
return prev;
}
return { ...prev, icon_size: clamped };
});
}
function setMapCenter(latitude: number, longitude: number) {
const epsilon = 1e-6;
@@ -579,6 +591,7 @@ export const MapDataProvider = observer(
setSightCoordinates,
setSightIconSize,
setFontSize,
setRouteIconSize,
}),
[
originalRouteData,
@@ -594,6 +607,7 @@ export const MapDataProvider = observer(
setStationIconSize,
setSightIconSize,
setFontSize,
setRouteIconSize,
]
);

View File

@@ -22,6 +22,7 @@ export function RightSidebar() {
setMapRotation,
setMapCenter,
setFontSize: updateFontSize,
setRouteIconSize: updateRouteIconSize,
} = useMapData();
const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
@@ -34,6 +35,7 @@ export function RightSidebar() {
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [fontSize, setFontSize] = useState<number>(100);
const [defaultIconSize, setDefaultIconSize] = useState<number>(100);
const [isSaving, setIsSaving] = useState<boolean>(false);
useEffect(() => {
@@ -52,6 +54,7 @@ export function RightSidebar() {
y: originalRouteData.center_longitude ?? 0,
});
setFontSize(originalRouteData.font_size ?? 100);
setDefaultIconSize(originalRouteData.icon_size ?? 100);
}
}, [originalRouteData]);
@@ -63,7 +66,7 @@ export function RightSidebar() {
useEffect(() => {
setRotationDegrees(
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360,
);
}, [rotation]);
@@ -108,6 +111,20 @@ export function RightSidebar() {
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
const handleDefaultIconSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(1, Math.min(300, Math.round(value)));
setDefaultIconSize(clamped);
updateRouteIconSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setDefaultIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
if (!routeData) {
return null;
}
@@ -317,6 +334,33 @@ export function RightSidebar() {
}}
/>
<TextField
type="number"
label="Размер иконок по умолчанию (%)"
variant="filled"
value={defaultIconSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleDefaultIconSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField
type="number"
label="Поворот (в градусах)"

View File

@@ -216,6 +216,12 @@ const rotateVertices = (
const DRAG_THRESHOLD_PX = 4;
const ICON_SIZE_MIN = 1;
const ICON_SIZE_MAX = 300;
const DEBUG_WEBGL_ROUTE_MAP = true;
const debugWebglLog = (...args: unknown[]) => {
if (!DEBUG_WEBGL_ROUTE_MAP) return;
console.log("[WebGLRouteMapPrototype]", ...args);
};
type StationDragState = {
stationId: number;
@@ -735,7 +741,16 @@ export const WebGLRouteMapPrototype = observer(() => {
next: Transform,
options?: { immediate?: boolean; skipClamp?: boolean },
) => {
const prevTransform = transformRef.current;
const adjusted = options?.skipClamp ? next : clampTransformScale(next);
debugWebglLog("updateTransform", {
immediate: Boolean(options?.immediate),
skipClamp: Boolean(options?.skipClamp),
prevScale: prevTransform?.scale ?? null,
nextScale: adjusted.scale,
prevTranslation: prevTransform?.translation ?? null,
nextTranslation: adjusted.translation,
});
transformRef.current = adjusted;
if (options?.immediate) {
@@ -1268,10 +1283,7 @@ export const WebGLRouteMapPrototype = observer(() => {
}
const safePercent = Math.max(ICON_SIZE_MIN, currentPercent);
const baseSizePxAt100 = Math.max(
1,
initialSizePx / (safePercent / 100),
);
const baseSizePxAt100 = Math.max(1, initialSizePx / (safePercent / 100));
sightIconResizeStateRef.current = {
sightId,
@@ -1376,6 +1388,10 @@ export const WebGLRouteMapPrototype = observer(() => {
if (typeof ResizeObserver === "undefined") {
const handleResize = () => {
debugWebglLog("container resize (fallback)", {
width: container.clientWidth,
height: container.clientHeight,
});
setCanvasSize({
width: container.clientWidth,
height: container.clientHeight,
@@ -1389,6 +1405,7 @@ export const WebGLRouteMapPrototype = observer(() => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
debugWebglLog("container resize", { width, height });
setCanvasSize({ width, height });
}
});
@@ -1418,6 +1435,15 @@ export const WebGLRouteMapPrototype = observer(() => {
const dpr = Math.max(1, window.devicePixelRatio || 1);
const displayWidth = Math.max(1, Math.floor(canvasSize.width * dpr));
const displayHeight = Math.max(1, Math.floor(canvasSize.height * dpr));
debugWebglLog("drawScene:start", {
cssCanvasWidth: canvasSize.width,
cssCanvasHeight: canvasSize.height,
dpr,
displayWidth,
displayHeight,
routeVerticesCount: rotatedRouteVertices.length / 2,
stationVerticesCount: rotatedStationVertices.length / 2,
});
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
@@ -1531,6 +1557,11 @@ export const WebGLRouteMapPrototype = observer(() => {
}
const { scale, translation } = transform;
debugWebglLog("drawScene:transform", {
scale,
translation,
scaleLimits: scaleLimitsRef.current,
});
const desiredRouteWidthCss = 7;
const desiredStationDiameterCss = 12;
@@ -1545,6 +1576,11 @@ export const WebGLRouteMapPrototype = observer(() => {
rotatedRouteVertices,
lineWidth,
);
debugWebglLog("drawScene:route", {
desiredRouteWidthCss,
lineWidthWorldUnits: lineWidth,
thickVerticesCount: thickVertices.length / 2,
});
if (thickVertices.length === 0) {
gl.bufferData(gl.ARRAY_BUFFER, rotatedRouteVertices, gl.STATIC_DRAW);
} else {
@@ -1635,6 +1671,10 @@ export const WebGLRouteMapPrototype = observer(() => {
pointOuterSizePx,
);
}
debugWebglLog("drawScene:stations", {
pointOuterSizePx,
pointInnerSizePx,
});
if (pointProgram.uniformLocations.u_color) {
gl.uniform4f(
pointProgram.uniformLocations.u_color,
@@ -1814,6 +1854,87 @@ export const WebGLRouteMapPrototype = observer(() => {
drawScene();
}, [drawScene]);
useEffect(() => {
const camera =
transformState ?? transformRef.current ?? lastTransformRef.current;
if (!camera || !sightData?.length) return;
const routeIconSizePercent =
routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
for (const sight of sightData) {
const shouldUseCustomSightIcon =
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon);
if (!shouldUseCustomSightIcon) continue;
const customSightIconScaleFactor =
camera.scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6);
const sightIconSizePercent =
liveSightIconSizes.get(sight.id) ??
(typeof sight.icon_size === "number" && Number.isFinite(sight.icon_size)
? sight.icon_size
: routeIconSizePercent);
const iconSizePx =
30 *
clamp(sightIconSizePercent / 100, 0.1, 10) *
customSightIconScaleFactor;
debugWebglLog("custom sight icon size", {
sightId: sight.id,
cameraScale: camera.scale,
baseScale: customSightIconBaseScaleRef.current,
scaleFactor: customSightIconScaleFactor,
iconPercent: sightIconSizePercent,
iconSizePx,
});
}
}, [
sightData,
liveSightIconSizes,
transformState,
routeData?.icon_size,
originalRouteData?.icon_size,
]);
useEffect(() => {
if (!stationData?.ru?.length) return;
const fontSizePercent =
routeData?.font_size ?? originalRouteData?.font_size ?? 100;
const fontScale = fontSizePercent / 100;
const baseStationIconSizePx = 16 * fontScale * 1.2;
for (const station of stationData.ru) {
const hasCustomStationIcon = !isMediaIdEmpty(station.icon);
if (!hasCustomStationIcon) continue;
const stationIconSizePercent =
liveStationIconSizes.get(station.id) ??
(typeof station.icon_size === "number" && Number.isFinite(station.icon_size)
? station.icon_size
: 100);
const iconSizePx = Math.max(
1,
Math.round(
baseStationIconSizePx * clamp(stationIconSizePercent / 100, 0.1, 10),
),
);
debugWebglLog("custom station icon size", {
stationId: station.id,
fontSizePercent,
baseStationIconSizePx,
iconPercent: stationIconSizePercent,
iconSizePx,
});
}
}, [
stationData?.ru,
liveStationIconSizes,
routeData?.font_size,
originalRouteData?.font_size,
]);
const applyCenterFromCoordinates = useCallback(
(latitude: number, longitude: number) => {
const roundedLat = Math.round(latitude * 1e6) / 1e6;
@@ -2167,6 +2288,7 @@ export const WebGLRouteMapPrototype = observer(() => {
return null;
}
const translatedStation = stationData?.[language]?.[index];
const enStation = stationData?.["en"]?.[index];
const local = coordinatesToLocal(
station.latitude,
@@ -2220,11 +2342,12 @@ export const WebGLRouteMapPrototype = observer(() => {
const rotationCss = `${rotationAngle}rad`;
const counterRotationCss = `${-rotationAngle}rad`;
const secondaryStation =
language === "ru" ? enStation : translatedStation;
const showSecondary =
language !== "ru" &&
translatedStation &&
translatedStation.name &&
translatedStation.name !== station.name;
secondaryStation &&
secondaryStation.name &&
secondaryStation.name !== station.name;
const fontSizePercent =
routeData?.font_size ?? originalRouteData?.font_size ?? 100;
@@ -2446,7 +2569,7 @@ export const WebGLRouteMapPrototype = observer(() => {
pointerEvents: "none",
}}
>
{translatedStation?.name}
{secondaryStation?.name}
</div>
) : null}
</div>
@@ -2572,8 +2695,7 @@ export const WebGLRouteMapPrototype = observer(() => {
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const shouldUseCustomSightIcon =
sight.is_default_icon === false &&
!isMediaIdEmpty(sight.icon);
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon);
const sightIconUrl = shouldUseCustomSightIcon
? `${mediaBaseUrl}${sight.icon}/download?token=${mediaToken}`
: SIGHT_ICON_URL;
@@ -2581,21 +2703,21 @@ export const WebGLRouteMapPrototype = observer(() => {
? camera.scale /
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
: 1;
const sightIconSizePercent =
liveSightIconSizes.get(sight.id) ??
const sightIconSizePercent = sight.is_default_icon === false
? (liveSightIconSizes.get(sight.id) ??
(typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
? sight.icon_size
: (routeData?.icon_size ??
originalRouteData?.icon_size ??
100));
: 100))
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
const iconSize =
30 *
clamp(sightIconSizePercent / 100, 0.1, 10) *
customSightIconScaleFactor;
const showSightResizeUi =
hoveredSightIconId === sight.id ||
resizingSightIconId === sight.id;
sight.is_default_icon !== true &&
(hoveredSightIconId === sight.id ||
resizingSightIconId === sight.id);
const iconLeft = cssX - iconSize;
const iconTop = cssY - iconSize;
const labelHeight = 24;

View File

@@ -6,6 +6,7 @@ import {
languageStore,
sightsStore,
selectedCityStore,
SearchInput,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
@@ -28,6 +29,7 @@ export const SightListPage = observer(() => {
});
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchSights = async () => {
@@ -120,14 +122,17 @@ export const SightListPage = observer(() => {
});
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
const canWriteSights = authStore.canWrite("sights");
const rows = filteredSights.map((sight) => ({
const query = searchQuery.trim().toLowerCase();
const rows = filteredSights
.filter((sight: any) => !query || (sight.name ?? "").toLowerCase().includes(query))
.map((sight) => ({
id: sight.id,
name: sight.name,
city_id: sight.city_id,
}));
const canWriteSights = authStore.canWrite("sights");
return (
<>
<LanguageSwitcher />
@@ -155,6 +160,8 @@ export const SightListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
@@ -19,6 +19,7 @@ export const SnapshotListPage = observer(() => {
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
@@ -89,22 +90,35 @@ export const SnapshotListPage = observer(() => {
}] : []),
];
const rows = snapshots.map((snapshot) => ({
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return snapshots
.filter(
(snapshot) =>
!query ||
(snapshot.Name ?? "").toLowerCase().includes(query) ||
(snapshots.find((s) => s.ID === snapshot.ParentID)?.Name ?? "").toLowerCase().includes(query)
)
.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
}));
}, [snapshots, searchQuery]);
return (
<>
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Экспорт Медиа</h1>
{canCreateSnapshot && (
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
)}
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}

View File

@@ -5,8 +5,9 @@ import {
languageStore,
stationsStore,
selectedCityStore,
SearchInput,
} from "@shared";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus, Route } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -36,6 +37,7 @@ export const StationListPage = observer(() => {
});
const { language } = languageStore;
const canWriteStations = authStore.canWrite("stations");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchStations = async () => {
@@ -121,21 +123,23 @@ export const StationListPage = observer(() => {
},
];
const filteredStations = () => {
const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return stationLists[language].data;
}
return stationLists[language].data.filter(
(station: any) => station.city_id === selectedCityId
);
};
const rows = filteredStations().map((station: any) => ({
const query = searchQuery.trim().toLowerCase();
return stationLists[language].data
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId)
.filter(
(station: any) =>
!query ||
(station.name ?? "").toLowerCase().includes(query) ||
(station.description ?? "").toLowerCase().includes(query)
)
.map((station: any) => ({
id: station.id,
name: station.name,
description: station.description,
}));
}, [stationLists[language].data, selectedCityStore.selectedCityId, searchQuery]);
return (
<>
@@ -161,6 +165,8 @@ export const StationListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, userStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, userStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -21,6 +21,7 @@ export const UserListPage = observer(() => {
pageSize: 50,
});
const canWriteUsers = authStore.canWrite("users");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchUsers = async () => {
@@ -107,12 +108,21 @@ export const UserListPage = observer(() => {
}] : []),
];
const rows = users.data?.map((user) => ({
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (users.data ?? [])
.filter((user) =>
!query ||
(user.name ?? "").toLowerCase().includes(query) ||
(user.email ?? "").toLowerCase().includes(query)
)
.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
name: user.name,
}));
}, [users.data, searchQuery]);
return (
<>
@@ -136,6 +146,8 @@ export const UserListPage = observer(() => {
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -24,6 +24,7 @@ export const VehicleListPage = observer(() => {
});
const { language } = languageStore;
const canWriteVehicles = authStore.canWrite("devices");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -136,7 +137,16 @@ export const VehicleListPage = observer(() => {
},
];
const rows = vehicles.data?.map((vehicle) => ({
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (vehicles.data ?? [])
.filter(
(vehicle) =>
!query ||
(vehicle.vehicle.tail_number ?? "").toLowerCase().includes(query) ||
(vehicle.vehicle.carrier ?? "").toLowerCase().includes(query)
)
.map((vehicle) => ({
id: vehicle.vehicle.id,
tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type,
@@ -145,18 +155,22 @@ export const VehicleListPage = observer(() => {
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
}));
}, [vehicles.data, carriers[language].data, searchQuery]);
return (
<>
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Транспортные средства</h1>
<CreateButton
label="Создать транспортное средство"
path="/vehicle/create"
/>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
{canWriteVehicles && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button

View File

@@ -193,11 +193,11 @@ class CarrierStore {
const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
const payload = {
full_name: this.createCarrierData[language].full_name,
short_name: this.createCarrierData[language].short_name,
full_name: (this.createCarrierData[language].full_name || "").trim(),
short_name: (this.createCarrierData[language].short_name || "").trim(),
city: cityName,
city_id: this.createCarrierData.city_id,
slogan: this.createCarrierData[language].slogan,
slogan: (this.createCarrierData[language].slogan || "").trim(),
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
@@ -218,13 +218,13 @@ class CarrierStore {
);
const patchPayload = {
// @ts-ignore
full_name: this.createCarrierData[lang as any].full_name as string,
full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(),
// @ts-ignore
short_name: this.createCarrierData[lang as any].short_name as string,
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(),
city: cityNameForLang || cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: this.createCarrierData[lang as any].slogan as string,
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
@@ -321,6 +321,9 @@ class CarrierStore {
const cityName = this.resolveCityName(this.editCarrierData.city_id, lang);
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang],
full_name: (this.editCarrierData[lang].full_name || "").trim(),
short_name: (this.editCarrierData[lang].short_name || "").trim(),
slogan: (this.editCarrierData[lang].slogan || "").trim(),
city: cityName,
city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo

View File

@@ -171,7 +171,7 @@ class CityStore {
try {
// Create city in primary language
const cityPayload = {
name,
name: name.trim(),
country:
countryStore.countries[language as keyof CashedCountries]?.data.find(
(c) => c.code === country_code
@@ -200,7 +200,7 @@ class CityStore {
)?.name || "";
const patchPayload = {
name: secondaryName || "",
name: (secondaryName || "").trim(),
country: countryName,
country_code: country_code || "",
...(arms ? { arms } : {}),
@@ -285,7 +285,7 @@ class CityStore {
);
await languageInstance(language as Language).patch(`/city/${code}`, {
name,
name: (name || "").trim(),
country: country?.name || "",
country_code: country_code,
arms,

View File

@@ -136,7 +136,7 @@ class CountryStore {
if (code && this.createCountryData[language].name) {
await languageInstance(language as Language).post("/country", {
code: code,
name: name,
name: name.trim(),
});
runInAction(() => {
@@ -156,7 +156,7 @@ class CountryStore {
await languageInstance(secondaryLanguage as Language).patch(
`/country/${code}`,
{
name: name,
name: name.trim(),
}
);
}
@@ -212,7 +212,7 @@ class CountryStore {
if (name) {
await languageInstance(language as Language).patch(`/country/${code}`, {
name: name,
name: name.trim(),
});
runInAction(() => {

View File

@@ -493,7 +493,7 @@ class CreateSightStore {
latitude: this.sight.latitude,
longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon,
name: this.sight[primaryLanguage].name,
name: (this.sight[primaryLanguage].name || "").trim(),
address: this.sight[primaryLanguage].address,
thumbnail: this.sight.thumbnail,
icon: this.sight.icon,
@@ -521,7 +521,7 @@ class CreateSightStore {
latitude: this.sight.latitude,
longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon,
name: this.sight[lang].name,
name: (this.sight[lang].name || "").trim(),
address: this.sight[lang].address,
thumbnail: this.sight.thumbnail,
icon: this.sight.icon,

View File

@@ -299,9 +299,9 @@ class EditSightStore {
...this.sight.common,
translations: {
name: {
ru: this.sight.ru.name,
en: this.sight.en.name,
zh: this.sight.zh.name,
ru: (this.sight.ru.name || "").trim(),
en: (this.sight.en.name || "").trim(),
zh: (this.sight.zh.name || "").trim(),
},
address: {
ru: this.sight.ru.address,

View File

@@ -170,6 +170,9 @@ class RouteStore {
}
const dataToSend: any = {
...this.editRouteData,
route_name: (this.editRouteData.route_name || "").trim(),
route_number: (this.editRouteData.route_number || "").trim(),
route_sys_number: (this.editRouteData.route_sys_number || "").trim(),
center_latitude: parseFloat(this.editRouteData.center_latitude),
center_longitude: parseFloat(this.editRouteData.center_longitude),
};

View File

@@ -285,7 +285,7 @@ class SnapshotStore {
const response = await authInstance.post(
`/snapshots`,
{ name },
{ name: name.trim() },
{ headers: { "X-Request-ID": this.lastRequestId } }
);

View File

@@ -287,10 +287,10 @@ class StationsStore {
const response = await languageInstance(language).patch(
`/station/${id}`,
{
name: name || "",
system_name: name || "",
description: description || "",
address: address || "",
name: (name || "").trim(),
system_name: (name || "").trim(),
description: (description || "").trim(),
address: (address || "").trim(),
...commonDataPayload,
}
);
@@ -415,10 +415,10 @@ class StationsStore {
const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", {
name: name || "",
system_name: name || "",
description: description || "",
address: address || "",
name: (name || "").trim(),
system_name: (name || "").trim(),
description: (description || "").trim(),
address: (address || "").trim(),
...commonDataPayload,
});
@@ -436,10 +436,10 @@ class StationsStore {
const response = await languageInstance(lang).patch(
`/station/${stationId}`,
{
name: name || "",
system_name: name || "",
description: description || "",
address: address || "",
name: (name || "").trim(),
system_name: (name || "").trim(),
description: (description || "").trim(),
address: (address || "").trim(),
...commonDataPayload,
}
);

View File

@@ -92,7 +92,11 @@ class UserStore {
if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1;
}
const payload: Partial<User> = { ...this.createUserData };
const payload: Partial<User> = {
...this.createUserData,
name: (this.createUserData.name || "").trim(),
email: (this.createUserData.email || "").trim(),
};
const baseRoles = new Set<string>(payload.roles ?? []);
baseRoles.add("articles_ro");
baseRoles.add("articles_rw");
@@ -141,7 +145,11 @@ class UserStore {
};
editUser = async (id: number) => {
const payload = { ...this.editUserData };
const payload = {
...this.editUserData,
name: (this.editUserData.name || "").trim(),
email: (this.editUserData.email || "").trim(),
};
if (!payload.icon) delete payload.icon;
if (!payload.password?.trim()) delete payload.password;

View File

@@ -122,9 +122,9 @@ class VehicleStore {
model?: string,
) => {
const payload: Record<string, unknown> = {
tail_number: tailNumber,
tail_number: tailNumber.trim(),
type,
carrier,
carrier: carrier.trim(),
carrier_id: carrierId,
};
// TODO: когда будет бекенд — добавить model в payload и в ответ
@@ -182,9 +182,9 @@ class VehicleStore {
},
) => {
const payload: Record<string, unknown> = {
tail_number: data.tail_number,
tail_number: data.tail_number.trim(),
type: data.type,
carrier: data.carrier,
carrier: data.carrier.trim(),
carrier_id: data.carrier_id,
};
if (data.model != null && data.model !== "") payload.model = data.model;

View File

@@ -0,0 +1,37 @@
import { Search, X } from "lucide-react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export const SearchInput = ({
value,
onChange,
placeholder = "Поиск...",
}: SearchInputProps) => {
return (
<div className="relative mb-4 w-full max-w-sm">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-md text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
/>
{value && (
<button
onClick={() => onChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={14} />
</button>
)}
</div>
);
};

View File

@@ -5,3 +5,4 @@ export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";
export * from "./MultiSelect";
export * from "./SearchInput";

File diff suppressed because one or more lines are too long