Compare commits
4 Commits
#14
...
5298fb9f60
| Author | SHA1 | Date | |
|---|---|---|---|
| 5298fb9f60 | |||
| c95a6517e9 | |||
| 79f523e9cb | |||
| 90f3d66b22 |
@@ -6,7 +6,7 @@ import React, {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Map, View, Overlay, MapBrowserEvent } from "ol";
|
||||
import { Map as OLMap, View, Overlay, MapBrowserEvent } from "ol";
|
||||
import TileLayer from "ol/layer/Tile";
|
||||
import OSM from "ol/source/OSM";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
@@ -48,6 +48,9 @@ import {
|
||||
InfoIcon,
|
||||
X,
|
||||
Loader2,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Map as MapIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { singleClick, doubleClick } from "ol/events/condition";
|
||||
@@ -136,6 +139,7 @@ interface ApiRoute {
|
||||
interface ApiStation {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city_id: number;
|
||||
@@ -162,14 +166,71 @@ export type SortType =
|
||||
| "updated_asc"
|
||||
| "updated_desc";
|
||||
|
||||
// --- HIDDEN ROUTES STORAGE ---
|
||||
const HIDDEN_ROUTES_KEY = "mapHiddenRoutes";
|
||||
const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes";
|
||||
|
||||
const getStoredHiddenRoutes = (): Set<number> => {
|
||||
try {
|
||||
const stored = localStorage.getItem(HIDDEN_ROUTES_KEY);
|
||||
if (stored) {
|
||||
const routes = JSON.parse(stored);
|
||||
if (
|
||||
Array.isArray(routes) &&
|
||||
routes.every((id) => typeof id === "number")
|
||||
) {
|
||||
return new Set(routes);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse stored hidden routes:", error);
|
||||
}
|
||||
return new Set();
|
||||
};
|
||||
|
||||
const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(Array.from(hiddenRoutes))
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save hidden routes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
class MapStore {
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
// Загружаем скрытые маршруты из localStorage при инициализации
|
||||
this.hiddenRoutes = getStoredHiddenRoutes();
|
||||
// Загружаем настройку скрытия достопримечательностей
|
||||
try {
|
||||
const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY);
|
||||
this.hideSightsByHiddenRoutes = stored
|
||||
? JSON.parse(stored) === true
|
||||
: false;
|
||||
} catch (e) {
|
||||
this.hideSightsByHiddenRoutes = false;
|
||||
}
|
||||
}
|
||||
|
||||
routes: ApiRoute[] = [];
|
||||
stations: ApiStation[] = [];
|
||||
sights: ApiSight[] = [];
|
||||
hiddenRoutes: Set<number>;
|
||||
hideSightsByHiddenRoutes: boolean = false;
|
||||
routeStationsCache: Map<number, number[]> = new Map(); // Кэш станций для маршрутов
|
||||
routeSightsCache: Map<number, number[]> = new Map(); // Кэш достопримечательностей для маршрутов
|
||||
setHideSightsByHiddenRoutes(val: boolean) {
|
||||
this.hideSightsByHiddenRoutes = val;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(!!val)
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ
|
||||
stationSort: SortType = "name_asc";
|
||||
@@ -297,12 +358,23 @@ class MapStore {
|
||||
|
||||
get filteredSights(): ApiSight[] {
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (!selectedCityId) {
|
||||
return this.sortedSights;
|
||||
const cityFiltered = !selectedCityId
|
||||
? this.sortedSights
|
||||
: this.sortedSights.filter((sight) => sight.city_id === selectedCityId);
|
||||
|
||||
if (!this.hideSightsByHiddenRoutes || this.hiddenRoutes.size === 0) {
|
||||
return cityFiltered;
|
||||
}
|
||||
return this.sortedSights.filter(
|
||||
(sight) => sight.city_id === selectedCityId
|
||||
);
|
||||
|
||||
// Собираем все достопримечательности, связанные со скрытыми маршрутами
|
||||
const hiddenSightIds = new Set<number>();
|
||||
this.hiddenRoutes.forEach((routeId) => {
|
||||
const sightIds = this.routeSightsCache.get(routeId) || [];
|
||||
sightIds.forEach((id) => hiddenSightIds.add(id));
|
||||
});
|
||||
|
||||
// Фильтруем достопримечательности, исключая привязанные к скрытым маршрутам
|
||||
return cityFiltered.filter((s) => !hiddenSightIds.has(s.id));
|
||||
}
|
||||
|
||||
getRoutes = async () => {
|
||||
@@ -324,6 +396,54 @@ class MapStore {
|
||||
this.routes = this.routes.sort((a, b) =>
|
||||
a.route_number.localeCompare(b.route_number)
|
||||
);
|
||||
|
||||
// Предзагружаем станции для всех маршрутов и кэшируем их
|
||||
await this.preloadRouteStations(routesIds);
|
||||
// Предзагружаем достопримечательности для всех маршрутов
|
||||
await this.preloadRouteSights(routesIds);
|
||||
};
|
||||
|
||||
preloadRouteStations = async (routesIds: number[]) => {
|
||||
console.log(
|
||||
`[MapStore] Preloading stations for ${routesIds.length} routes`
|
||||
);
|
||||
const stationPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const stationsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/station`
|
||||
);
|
||||
const stationIds = stationsResponse.data.map((s: any) => s.id);
|
||||
this.routeStationsCache.set(routeId, stationIds);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to preload stations for route ${routeId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
await Promise.all(stationPromises);
|
||||
console.log(
|
||||
`[MapStore] Preloaded stations for ${this.routeStationsCache.size} routes`
|
||||
);
|
||||
};
|
||||
|
||||
preloadRouteSights = async (routesIds: number[]) => {
|
||||
console.log(`[MapStore] Preloading sights for ${routesIds.length} routes`);
|
||||
const sightPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const sightsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/sight`
|
||||
);
|
||||
const sightIds = sightsResponse.data.map((s: any) => s.id);
|
||||
this.routeSightsCache.set(routeId, sightIds);
|
||||
} catch (error) {
|
||||
console.error(`Failed to preload sights for route ${routeId}:`, error);
|
||||
}
|
||||
});
|
||||
await Promise.all(sightPromises);
|
||||
console.log(
|
||||
`[MapStore] Preloaded sights for ${this.routeSightsCache.size} routes`
|
||||
);
|
||||
};
|
||||
|
||||
getStations = async () => {
|
||||
@@ -430,8 +550,8 @@ class MapStore {
|
||||
rotate: 0,
|
||||
route_direction: false,
|
||||
route_sys_number: route_number,
|
||||
scale_max: 0,
|
||||
scale_min: 0,
|
||||
scale_max: 100,
|
||||
scale_min: 10,
|
||||
};
|
||||
|
||||
await routeStore.createRoute(routeData);
|
||||
@@ -651,7 +771,7 @@ interface MapServiceConfig {
|
||||
type FeatureType = "station" | "route" | "sight";
|
||||
|
||||
class MapService {
|
||||
private map: Map | null;
|
||||
private map: OLMap | null;
|
||||
public pointSource: VectorSource<Feature<Point>>;
|
||||
public lineSource: VectorSource<Feature<LineString>>;
|
||||
public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler
|
||||
@@ -943,7 +1063,7 @@ class MapService {
|
||||
const initialCenter = storedPosition?.center || config.center;
|
||||
const initialZoom = storedPosition?.zoom || config.zoom;
|
||||
|
||||
this.map = new Map({
|
||||
this.map = new OLMap({
|
||||
target: config.target,
|
||||
layers: [
|
||||
new TileLayer({ source: new OSM() }),
|
||||
@@ -1251,32 +1371,17 @@ class MapService {
|
||||
}
|
||||
}
|
||||
|
||||
// Стандартная логика выделения для одиночных объектов (или с Ctrl)
|
||||
// При Ctrl+клик сохраняем предыдущие выделения и добавляем/удаляем только изменённые
|
||||
// При обычном клике создаём новый набор
|
||||
const newSelectedIds = ctrlKey
|
||||
? new Set(this.selectedIds)
|
||||
: new Set<string | number>();
|
||||
|
||||
// Добавляем новые выбранные элементы
|
||||
e.selected.forEach((feature) => {
|
||||
const originalFeatures = feature.get("features");
|
||||
let targetId: string | number | undefined;
|
||||
|
||||
if (originalFeatures && originalFeatures.length > 0) {
|
||||
// Это фича из кластера (может быть и одна)
|
||||
targetId = originalFeatures[0].getId();
|
||||
} else {
|
||||
// Это линия или что-то не из кластера
|
||||
targetId = feature.getId();
|
||||
}
|
||||
|
||||
if (targetId !== undefined) {
|
||||
newSelectedIds.add(targetId);
|
||||
}
|
||||
});
|
||||
|
||||
e.deselected.forEach((feature) => {
|
||||
const originalFeatures = feature.get("features");
|
||||
let targetId: string | number | undefined;
|
||||
|
||||
if (originalFeatures && originalFeatures.length > 0) {
|
||||
targetId = originalFeatures[0].getId();
|
||||
} else {
|
||||
@@ -1284,10 +1389,36 @@ class MapService {
|
||||
}
|
||||
|
||||
if (targetId !== undefined) {
|
||||
newSelectedIds.delete(targetId);
|
||||
// При Ctrl+клик: если элемент уже был выбран, снимаем его (toggle)
|
||||
// Если не был выбран, добавляем
|
||||
if (ctrlKey && newSelectedIds.has(targetId)) {
|
||||
newSelectedIds.delete(targetId);
|
||||
} else {
|
||||
newSelectedIds.add(targetId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// При Ctrl+клик игнорируем deselected, так как Select interaction может снимать
|
||||
// предыдущие выделения, но мы хотим их сохранить
|
||||
// При обычном клике удаляем deselected элементы
|
||||
if (!ctrlKey) {
|
||||
e.deselected.forEach((feature) => {
|
||||
const originalFeatures = feature.get("features");
|
||||
let targetId: string | number | undefined;
|
||||
|
||||
if (originalFeatures && originalFeatures.length > 0) {
|
||||
targetId = originalFeatures[0].getId();
|
||||
} else {
|
||||
targetId = feature.getId();
|
||||
}
|
||||
|
||||
if (targetId !== undefined) {
|
||||
newSelectedIds.delete(targetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setSelectedIds(newSelectedIds);
|
||||
});
|
||||
|
||||
@@ -1373,8 +1504,33 @@ class MapService {
|
||||
const filteredSights = mapStore.filteredSights;
|
||||
const filteredRoutes = mapStore.filteredRoutes;
|
||||
|
||||
console.log(
|
||||
`[loadFeaturesFromApi] Loading with ${mapStore.hiddenRoutes.size} hidden routes`
|
||||
);
|
||||
|
||||
// Собираем все станции видимых маршрутов из кэша
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
filteredRoutes
|
||||
.filter((route) => !mapStore.hiddenRoutes.has(route.id))
|
||||
.forEach((route) => {
|
||||
const stationIds = mapStore.routeStationsCache.get(route.id) || [];
|
||||
stationIds.forEach((id) => stationsInVisibleRoutes.add(id));
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[loadFeaturesFromApi] Found ${stationsInVisibleRoutes.size} stations in visible routes, total stations: ${filteredStations.length}`
|
||||
);
|
||||
|
||||
let skippedStations = 0;
|
||||
filteredStations.forEach((station) => {
|
||||
if (station.longitude == null || station.latitude == null) return;
|
||||
|
||||
// Пропускаем станции, которые принадлежат только скрытым маршрутам
|
||||
if (!stationsInVisibleRoutes.has(station.id)) {
|
||||
skippedStations++;
|
||||
return;
|
||||
}
|
||||
|
||||
const point = new Point(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
@@ -1405,6 +1561,10 @@ class MapService {
|
||||
|
||||
filteredRoutes.forEach((route) => {
|
||||
if (!route.path || route.path.length === 0) return;
|
||||
|
||||
// Пропускаем скрытые маршруты
|
||||
if (mapStore.hiddenRoutes.has(route.id)) return;
|
||||
|
||||
const coordinates = route.path
|
||||
.filter((c) => c && c[0] != null && c[1] != null)
|
||||
.map((c: [number, number]) =>
|
||||
@@ -1423,6 +1583,10 @@ class MapService {
|
||||
lineFeatures.push(lineFeature);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[loadFeaturesFromApi] Skipped ${skippedStations} stations (belonging only to hidden routes)`
|
||||
);
|
||||
|
||||
this.pointSource.addFeatures(pointFeatures);
|
||||
this.lineSource.addFeatures(lineFeatures);
|
||||
|
||||
@@ -1880,10 +2044,14 @@ class MapService {
|
||||
this.selectInteraction.getFeatures().clear();
|
||||
ids.forEach((id) => {
|
||||
const lineFeature = this.lineSource.getFeatureById(id);
|
||||
if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature);
|
||||
if (lineFeature) {
|
||||
this.selectInteraction.getFeatures().push(lineFeature);
|
||||
}
|
||||
|
||||
const pointFeature = this.pointSource.getFeatureById(id);
|
||||
if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature);
|
||||
if (pointFeature) {
|
||||
this.selectInteraction.getFeatures().push(pointFeature);
|
||||
}
|
||||
});
|
||||
|
||||
this.modifyInteraction.setActive(
|
||||
@@ -1915,7 +2083,7 @@ class MapService {
|
||||
if (this.mode === "lasso") this.deactivateLasso();
|
||||
else this.activateLasso();
|
||||
}
|
||||
public getMap(): Map | null {
|
||||
public getMap(): OLMap | null {
|
||||
return this.map;
|
||||
}
|
||||
|
||||
@@ -2208,6 +2376,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
)
|
||||
),
|
||||
name: station.name,
|
||||
description: station.description || "",
|
||||
});
|
||||
feature.setId(`station-${station.id}`);
|
||||
feature.set("featureType", "station");
|
||||
@@ -2263,11 +2432,26 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
}, [allFeatures, searchQuery]);
|
||||
|
||||
const handleFeatureClick = useCallback(
|
||||
(id: string | number) => {
|
||||
(id: string | number, event?: React.MouseEvent) => {
|
||||
if (!mapService) return;
|
||||
mapService.selectFeature(id);
|
||||
const ctrlKey = event?.ctrlKey || event?.metaKey;
|
||||
|
||||
if (ctrlKey) {
|
||||
// Множественный выбор: добавляем к существующему
|
||||
const newSet = new Set(selectedIds);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
setSelectedIds(newSet);
|
||||
mapService.setSelectedIds(newSet);
|
||||
} else {
|
||||
// Одиночный выбор: используем стандартный метод
|
||||
mapService.selectFeature(id);
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -2313,6 +2497,217 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleHideRoute = useCallback(
|
||||
async (routeId: string | number) => {
|
||||
if (!mapService) return;
|
||||
|
||||
const numericRouteId = parseInt(String(routeId).split("-")[1], 10);
|
||||
if (isNaN(numericRouteId)) return;
|
||||
|
||||
const isHidden = mapStore.hiddenRoutes.has(numericRouteId);
|
||||
console.log(
|
||||
`[handleHideRoute] Route ${numericRouteId}, isHidden: ${isHidden}`
|
||||
);
|
||||
|
||||
try {
|
||||
if (isHidden) {
|
||||
console.log(`[handleHideRoute] Showing route ${numericRouteId}`);
|
||||
// Показываем маршрут обратно
|
||||
const route = mapStore.routes.find((r) => r.id === numericRouteId);
|
||||
if (!route) {
|
||||
console.warn(
|
||||
`[handleHideRoute] Route ${numericRouteId} not found in mapStore`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projection = mapService.getMap()?.getView().getProjection();
|
||||
if (!projection) {
|
||||
console.error(`[handleHideRoute] Failed to get map projection`);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[handleHideRoute] Route ${numericRouteId} (${route.route_number}) found, showing`
|
||||
);
|
||||
|
||||
// Показываем сам маршрут
|
||||
const coordinates = route.path
|
||||
.filter((c) => c && c[0] != null && c[1] != null)
|
||||
.map((c: [number, number]) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
);
|
||||
|
||||
if (coordinates.length > 0) {
|
||||
const line = new LineString(coordinates);
|
||||
const lineFeature = new Feature({
|
||||
geometry: line,
|
||||
name: route.route_number,
|
||||
});
|
||||
lineFeature.setId(routeId);
|
||||
lineFeature.set("featureType", "route");
|
||||
mapService.lineSource.addFeature(lineFeature);
|
||||
console.log(`[handleHideRoute] Added route line to map`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[handleHideRoute] No valid coordinates for route ${numericRouteId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Получаем станции текущего маршрута из кэша
|
||||
const routeStationIds =
|
||||
mapStore.routeStationsCache.get(numericRouteId) || [];
|
||||
console.log(
|
||||
`[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations`
|
||||
);
|
||||
|
||||
// Получаем все маршруты для проверки
|
||||
const allRouteIds = mapStore.routes.map((r) => r.id);
|
||||
|
||||
// Исключаем скрытые маршруты из проверки
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
);
|
||||
console.log(
|
||||
`[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)`
|
||||
);
|
||||
|
||||
// Собираем все станции видимых маршрутов из кэша
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
visibleRouteIds.forEach((otherRouteId) => {
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes`
|
||||
);
|
||||
|
||||
// Показываем станции, которые не используются в других ВИДИМЫХ маршрутах
|
||||
const stationsToShow = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
);
|
||||
|
||||
// Показываем станции на карте
|
||||
for (const stationId of stationsToShow) {
|
||||
const station = mapStore.stations.find((s) => s.id === stationId);
|
||||
if (!station) continue;
|
||||
|
||||
const point = new Point(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
name: station.name,
|
||||
});
|
||||
feature.setId(`station-${station.id}`);
|
||||
feature.set("featureType", "station");
|
||||
|
||||
// Добавляем станцию только если её еще нет на карте
|
||||
const existingFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${station.id}`
|
||||
);
|
||||
if (!existingFeature) {
|
||||
mapService.pointSource.addFeature(feature);
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем из скрытых
|
||||
mapStore.hiddenRoutes.delete(numericRouteId);
|
||||
saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage
|
||||
console.log(
|
||||
`[handleHideRoute] Removed ${numericRouteId} from hiddenRoutes, stations to show: ${stationsToShow.length}`
|
||||
);
|
||||
} else {
|
||||
// Скрываем маршрут
|
||||
console.log(`[handleHideRoute] Hiding route ${numericRouteId}`);
|
||||
|
||||
// Получаем станции текущего маршрута из кэша
|
||||
const routeStationIds =
|
||||
mapStore.routeStationsCache.get(numericRouteId) || [];
|
||||
console.log(
|
||||
`[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations`
|
||||
);
|
||||
|
||||
// Получаем все маршруты для проверки
|
||||
const allRouteIds = mapStore.routes.map((r) => r.id);
|
||||
|
||||
// Исключаем скрытые маршруты из проверки
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
);
|
||||
console.log(
|
||||
`[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)`
|
||||
);
|
||||
|
||||
// Собираем все станции видимых маршрутов из кэша
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
visibleRouteIds.forEach((otherRouteId) => {
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes`
|
||||
);
|
||||
|
||||
// Скрываем станции, которые не используются в других ВИДИМЫХ маршрутах
|
||||
const stationsToHide = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
);
|
||||
|
||||
// Скрываем станции с карты
|
||||
stationsToHide.forEach((stationId: number) => {
|
||||
const pointFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${stationId}`
|
||||
);
|
||||
if (pointFeature) {
|
||||
mapService.pointSource.removeFeature(
|
||||
pointFeature as Feature<Point>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Скрываем сам маршрут с карты
|
||||
const lineFeature = mapService.lineSource.getFeatureById(routeId);
|
||||
if (lineFeature) {
|
||||
mapService.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
);
|
||||
}
|
||||
|
||||
// Добавляем в скрытые
|
||||
mapStore.hiddenRoutes.add(numericRouteId);
|
||||
saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage
|
||||
console.log(
|
||||
`[handleHideRoute] Added ${numericRouteId} to hiddenRoutes, stations to hide: ${stationsToHide.length}`
|
||||
);
|
||||
}
|
||||
|
||||
// Снимаем выделение
|
||||
mapService.unselect();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[handleHideRoute] Error toggling route visibility:",
|
||||
error
|
||||
);
|
||||
toast.error("Ошибка при изменении видимости маршрута");
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
);
|
||||
|
||||
const sortFeaturesByType = <T extends Feature<Geometry>>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
@@ -2411,11 +2806,28 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const isSelected = selectedFeature?.getId() === fId;
|
||||
const isChecked = selectedIds.has(fId);
|
||||
|
||||
// Проверяем, скрыт ли маршрут
|
||||
const numericRouteId =
|
||||
featureType === "route"
|
||||
? parseInt(String(fId).split("-")[1], 10)
|
||||
: null;
|
||||
const isRouteHidden =
|
||||
numericRouteId !== null &&
|
||||
mapStore.hiddenRoutes.has(numericRouteId);
|
||||
|
||||
const description = feature.get("description") as
|
||||
| string
|
||||
| undefined;
|
||||
const showDescription =
|
||||
featureType === "station" &&
|
||||
description &&
|
||||
description.trim() !== "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(fId)}
|
||||
data-feature-id={fId}
|
||||
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
|
||||
className={`flex items-start p-2 rounded-md group transition-colors duration-150 relative ${
|
||||
isSelected
|
||||
? "bg-orange-100 border border-orange-300"
|
||||
: "hover:bg-blue-50"
|
||||
@@ -2433,7 +2845,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
|
||||
onClick={() => handleFeatureClick(fId)}
|
||||
onClick={(e) => handleFeatureClick(fId, e)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<IconComponent
|
||||
@@ -2458,6 +2870,11 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
{fName}
|
||||
</span>
|
||||
</div>
|
||||
{showDescription && (
|
||||
<div className="mt-1 text-xs text-gray-600 line-clamp-2">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
|
||||
<button
|
||||
@@ -2472,6 +2889,37 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
{featureType === "route" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const routeId = parseInt(String(fId).split("-")[1], 10);
|
||||
navigate(`/route-preview/${routeId}`);
|
||||
}}
|
||||
className="p-1 rounded-full text-gray-400 hover:text-green-600 hover:bg-green-100 transition-colors"
|
||||
title="Предпросмотр маршрута"
|
||||
>
|
||||
<MapIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
{featureType === "route" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHideRoute(fId);
|
||||
}}
|
||||
className={`p-1 rounded-full transition-colors ${
|
||||
isRouteHidden
|
||||
? "text-yellow-600 hover:text-yellow-700 hover:bg-yellow-100"
|
||||
: "text-gray-400 hover:text-yellow-600 hover:bg-yellow-100"
|
||||
}`}
|
||||
title={
|
||||
isRouteHidden ? "Показать на карте" : "Скрыть с карты"
|
||||
}
|
||||
>
|
||||
{isRouteHidden ? <Eye size={16} /> : <EyeOff size={16} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -2495,6 +2943,17 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const toggleSection = (id: string) =>
|
||||
setActiveSection(activeSection === id ? null : id);
|
||||
|
||||
const [showSightsOptions, setShowSightsOptions] = useState(false);
|
||||
const sightsOptionsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sightsOptionsTimeoutRef.current) {
|
||||
clearTimeout(sightsOptionsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: "layers",
|
||||
@@ -2534,20 +2993,60 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
icon: <Landmark size={20} />,
|
||||
count: sortedSights.length,
|
||||
sortControl: (
|
||||
<div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200">
|
||||
<label className="text-sm text-gray-700">Сортировка:</label>
|
||||
<select
|
||||
value={sightSort}
|
||||
onChange={(e) => setSightSort(e.target.value as SortType)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
<div className="flex items-center justify-between gap-4 p-3 bg-white border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm text-gray-700">Сортировка:</label>
|
||||
<select
|
||||
value={sightSort}
|
||||
onChange={(e) => setSightSort(e.target.value as SortType)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="name_asc">Имя ↑</option>
|
||||
<option value="name_desc">Имя ↓</option>
|
||||
<option value="created_asc">Дата создания ↑</option>
|
||||
<option value="created_desc">Дата создания ↓</option>
|
||||
<option value="updated_asc">Дата обновления ↑</option>
|
||||
<option value="updated_desc">Дата обновления ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => {
|
||||
sightsOptionsTimeoutRef.current = setTimeout(() => {
|
||||
setShowSightsOptions(true);
|
||||
}, 1000);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (sightsOptionsTimeoutRef.current) {
|
||||
clearTimeout(sightsOptionsTimeoutRef.current);
|
||||
sightsOptionsTimeoutRef.current = null;
|
||||
}
|
||||
setShowSightsOptions(false);
|
||||
}}
|
||||
>
|
||||
<option value="name_asc">Имя ↑</option>
|
||||
<option value="name_desc">Имя ↓</option>
|
||||
<option value="created_asc">Дата создания ↑</option>
|
||||
<option value="created_desc">Дата создания ↓</option>
|
||||
<option value="updated_asc">Дата обновления ↑</option>
|
||||
<option value="updated_desc">Дата обновления ↓</option>
|
||||
</select>
|
||||
<button
|
||||
className={`px-2 py-1 rounded text-sm transition-colors ${
|
||||
mapStore.hideSightsByHiddenRoutes
|
||||
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() =>
|
||||
mapStore.setHideSightsByHiddenRoutes(
|
||||
!mapStore.hideSightsByHiddenRoutes
|
||||
)
|
||||
}
|
||||
>
|
||||
Скрыть
|
||||
</button>
|
||||
{showSightsOptions && (
|
||||
<div className="absolute right-0 mt-2 w-50 bg-white border border-gray-200 rounded-md shadow-md p-3 z-5000">
|
||||
<div className="text-xs text-gray-600">
|
||||
Будут скрыты все достопримечательности, привязанные к
|
||||
скрытым маршрутам.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
content: renderFeatureList(sortedSights, "sight", Landmark),
|
||||
@@ -2854,6 +3353,27 @@ export const MapPage: React.FC = observer(() => {
|
||||
}
|
||||
}, [selectedCityId, mapServiceInstance, isDataLoading]);
|
||||
|
||||
// Перезагружаем данные при изменении настройки скрытия достопримечательностей
|
||||
useEffect(() => {
|
||||
if (mapServiceInstance && !isDataLoading) {
|
||||
// Очищаем текущие объекты на карте
|
||||
mapServiceInstance.pointSource.clear();
|
||||
mapServiceInstance.lineSource.clear();
|
||||
|
||||
// Загружаем новые данные с учетом фильтрации достопримечательностей
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
);
|
||||
}
|
||||
}, [
|
||||
mapStore.hideSightsByHiddenRoutes,
|
||||
mapStore.hiddenRoutes.size,
|
||||
mapServiceInstance,
|
||||
isDataLoading,
|
||||
]);
|
||||
|
||||
const showLoader = isMapLoading || isDataLoading;
|
||||
const showContent = mapServiceInstance && !showLoader && !error;
|
||||
const isAnythingSelected =
|
||||
|
||||
@@ -562,7 +562,14 @@ const LinkedItemsContentsInner = <
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
label={
|
||||
<div className="flex justify-between items-center w-full gap-10">
|
||||
<p>{String(item.name)}</p>
|
||||
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
|
||||
{String(item.description)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
@@ -24,9 +24,10 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||
import {
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
@@ -37,8 +38,9 @@ export const RouteCreatePage = observer(() => {
|
||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||
const [direction, setDirection] = useState("backward");
|
||||
const [scaleMin, setScaleMin] = useState("");
|
||||
const [scaleMax, setScaleMax] = useState("");
|
||||
const [scaleMin, setScaleMin] = useState("10");
|
||||
const [scaleMax, setScaleMax] = useState("100");
|
||||
const [routeName, setRouteName] = useState("");
|
||||
const [turn, setTurn] = useState("");
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
@@ -48,6 +50,8 @@ export const RouteCreatePage = observer(() => {
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,7 +59,6 @@ export const RouteCreatePage = observer(() => {
|
||||
articlesStore.getArticleList();
|
||||
}, [language]);
|
||||
|
||||
// Фильтруем перевозчиков только из выбранного города
|
||||
const filteredCarriers = useMemo(() => {
|
||||
const carriers =
|
||||
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||||
@@ -110,6 +113,8 @@ export const RouteCreatePage = observer(() => {
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
setGovernorAppeal(articleId.toString());
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
// Обновляем список статей после создания новой
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
@@ -122,6 +127,26 @@ export const RouteCreatePage = observer(() => {
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoFileSelect = (file?: File) => {
|
||||
if (file) {
|
||||
setFileToUpload(file);
|
||||
setIsUploadVideoDialogOpen(true);
|
||||
} else {
|
||||
setIsSelectVideoDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoUpload = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setVideoPreview(media.id);
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
setIsVideoPreviewOpen(true);
|
||||
};
|
||||
@@ -129,22 +154,75 @@ export const RouteCreatePage = observer(() => {
|
||||
const handleCreateRoute = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Преобразуем значения в нужные типы
|
||||
const carrier_id = Number(carrier);
|
||||
const governor_appeal = Number(governorAppeal);
|
||||
const scale_min = scaleMin ? Number(scaleMin) : undefined;
|
||||
const scale_max = scaleMax ? Number(scaleMax) : undefined;
|
||||
const rotate = turn ? Number(turn) : undefined;
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
const route_direction = direction === "forward";
|
||||
|
||||
// Валидация обязательных полей
|
||||
if (!routeName.trim()) {
|
||||
toast.error("Заполните название маршрута");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!carrier) {
|
||||
toast.error("Выберите перевозчика");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!routeNumber.trim()) {
|
||||
toast.error("Заполните номер маршрута");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!govRouteNumber.trim()) {
|
||||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!governorAppeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация масштабов
|
||||
const scale_min = scaleMin ? Number(scaleMin) : null;
|
||||
const scale_max = scaleMax ? Number(scaleMax) : null;
|
||||
console.log(scale_min, scale_max);
|
||||
if (
|
||||
scale_min === 0 ||
|
||||
scale_max === 0 ||
|
||||
scale_min === null ||
|
||||
scale_max === null
|
||||
) {
|
||||
toast.error("Масштабы не могут быть равны 0");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
scale_min !== null &&
|
||||
scale_max !== null &&
|
||||
scale_max !== undefined &&
|
||||
scale_min > scale_max
|
||||
) {
|
||||
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Преобразуем значения в нужные типы
|
||||
const carrier_id = Number(carrier);
|
||||
const governor_appeal = Number(governorAppeal);
|
||||
const rotate = turn ? Number(turn) : undefined;
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
const route_direction = direction === "forward";
|
||||
|
||||
// Координаты маршрута как массив массивов чисел
|
||||
const path = routeCoords
|
||||
.trim()
|
||||
@@ -167,9 +245,10 @@ export const RouteCreatePage = observer(() => {
|
||||
route_number: routeNumber,
|
||||
route_sys_number: govRouteNumber,
|
||||
governor_appeal,
|
||||
route_name: routeName,
|
||||
route_direction,
|
||||
scale_min,
|
||||
scale_max,
|
||||
scale_min: scale_min !== null ? scale_min : 0,
|
||||
scale_max: scale_max !== null ? scale_max : 0,
|
||||
rotate,
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
@@ -208,6 +287,13 @@ export const RouteCreatePage = observer(() => {
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название маршрута"
|
||||
required
|
||||
value={routeName}
|
||||
onChange={(e) => setRouteName(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
@@ -247,7 +333,6 @@ export const RouteCreatePage = observer(() => {
|
||||
const lines = routeCoords.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = routeCoords + "\n";
|
||||
@@ -279,6 +364,7 @@ export const RouteCreatePage = observer(() => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
@@ -287,99 +373,42 @@ export const RouteCreatePage = observer(() => {
|
||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Обращение к пассажирам
|
||||
</Typography>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
videoPreview && videoPreview !== "" ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{videoPreview && videoPreview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVideoPreview("");
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-lg font-bold">
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={videoPreview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
setVideoPreview("");
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
@@ -395,15 +424,42 @@ export const RouteCreatePage = observer(() => {
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
type="number"
|
||||
value={scaleMin}
|
||||
onChange={(e) => setScaleMin(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setScaleMin(value);
|
||||
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||||
if (value && scaleMax && Number(value) > Number(scaleMax)) {
|
||||
setScaleMax(value);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
scaleMin !== "" &&
|
||||
scaleMax !== "" &&
|
||||
Number(scaleMin) > Number(scaleMax)
|
||||
}
|
||||
required
|
||||
helperText={
|
||||
scaleMin !== "" &&
|
||||
scaleMax !== "" &&
|
||||
Number(scaleMin) > Number(scaleMax)
|
||||
? "Минимальный масштаб не может быть больше максимального"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
type="number"
|
||||
value={scaleMax}
|
||||
onChange={(e) => setScaleMax(e.target.value)}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setScaleMax(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
@@ -440,23 +496,17 @@ export const RouteCreatePage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
<ArticleSelectOrCreateDialog
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
@@ -483,6 +533,18 @@ export const RouteCreatePage = observer(() => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
<UploadMediaDialog
|
||||
open={isUploadVideoDialogOpen}
|
||||
onClose={() => {
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
}}
|
||||
hardcodeType="video_preview"
|
||||
contextObjectName={routeName || "Маршрут"}
|
||||
contextType="sight"
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -24,8 +24,9 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import {
|
||||
routeStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
@@ -40,12 +41,13 @@ export const RouteEditPage = observer(() => {
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
languageStore.setLanguage("ru");
|
||||
@@ -72,10 +74,67 @@ export const RouteEditPage = observer(() => {
|
||||
}, [editRouteData.path]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Валидация обязательных полей
|
||||
if (!editRouteData.route_name?.trim()) {
|
||||
toast.error("Заполните название маршрута");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.carrier_id) {
|
||||
toast.error("Выберите перевозчика");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.route_number?.trim()) {
|
||||
toast.error("Заполните номер маршрута");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.route_sys_number?.trim()) {
|
||||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.governor_appeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(coordinates);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация масштабов
|
||||
if (
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
editRouteData.scale_min > editRouteData.scale_max
|
||||
) {
|
||||
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
editRouteData.scale_min === 0 ||
|
||||
editRouteData.scale_max === 0 ||
|
||||
editRouteData.scale_min === null ||
|
||||
editRouteData.scale_max === null
|
||||
) {
|
||||
toast.error("Масштабы не могут быть равны 0");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await routeStore.editRoute(Number(id));
|
||||
toast.success("Маршрут успешно сохранен");
|
||||
setIsLoading(false);
|
||||
try {
|
||||
await routeStore.editRoute(Number(id));
|
||||
toast.success("Маршрут успешно сохранен");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Произошла ошибка при сохранении маршрута");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
@@ -125,6 +184,8 @@ export const RouteEditPage = observer(() => {
|
||||
governor_appeal: articleId,
|
||||
});
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
// Обновляем список статей после создания новой
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
@@ -139,6 +200,28 @@ export const RouteEditPage = observer(() => {
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoFileSelect = (file?: File) => {
|
||||
if (file) {
|
||||
setFileToUpload(file);
|
||||
setIsUploadVideoDialogOpen(true);
|
||||
} else {
|
||||
setIsSelectVideoDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoUpload = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||
setIsVideoPreviewOpen(true);
|
||||
@@ -164,6 +247,17 @@ export const RouteEditPage = observer(() => {
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название маршрута"
|
||||
required
|
||||
value={editRouteData.route_name || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
@@ -235,7 +329,6 @@ export const RouteEditPage = observer(() => {
|
||||
const lines = coordinates.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = coordinates + "\n";
|
||||
@@ -279,110 +372,6 @@ export const RouteEditPage = observer(() => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-lg font-bold"
|
||||
>
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
@@ -401,17 +390,33 @@ export const RouteEditPage = observer(() => {
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
type="number"
|
||||
value={editRouteData.scale_min ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value === "" ? null : parseFloat(e.target.value);
|
||||
routeStore.setEditRouteData({
|
||||
scale_min:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
scale_min: value,
|
||||
});
|
||||
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||||
if (
|
||||
value !== null &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
value > editRouteData.scale_max
|
||||
) {
|
||||
routeStore.setEditRouteData({
|
||||
scale_max: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
required
|
||||
label="Масштаб (макс)"
|
||||
type="number"
|
||||
value={editRouteData.scale_max ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
@@ -419,6 +424,22 @@ export const RouteEditPage = observer(() => {
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
error={
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
editRouteData.scale_max < editRouteData.scale_min
|
||||
}
|
||||
helperText={
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
editRouteData.scale_max < editRouteData.scale_min
|
||||
? "Максимальный масштаб не может быть меньше минимального"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
@@ -453,6 +474,43 @@ export const RouteEditPage = observer(() => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Обращение к пассажирам
|
||||
</Typography>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={editRouteData.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinkedItems
|
||||
@@ -493,23 +551,17 @@ export const RouteEditPage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
<ArticleSelectOrCreateDialog
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
@@ -519,19 +571,33 @@ export const RouteEditPage = observer(() => {
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
{editRouteData.video_preview && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<UploadMediaDialog
|
||||
open={isUploadVideoDialogOpen}
|
||||
onClose={() => {
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
}}
|
||||
hardcodeType="video_preview"
|
||||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||
contextType="sight"
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,22 @@ export const RouteListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_name",
|
||||
headerName: "Название маршрута",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_number",
|
||||
headerName: "Номер маршрута",
|
||||
@@ -100,9 +116,7 @@ export const RouteListPage = observer(() => {
|
||||
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||
<Map size={20} className="text-purple-500" />
|
||||
</button>
|
||||
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -122,6 +136,7 @@ export const RouteListPage = observer(() => {
|
||||
carrier_id: route.carrier_id,
|
||||
route_number: route.route_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
route_name: route.route_name,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -118,7 +118,7 @@ export function RightSidebar() {
|
||||
borderRadius={2}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
Детали о достопримечательностях
|
||||
Настройка маршрута
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} direction="row" alignItems="center">
|
||||
|
||||
1792
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
1792
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
src/pages/Sight/LinkedStations.tsx
Normal file
321
src/pages/Sight/LinkedStations.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedStationsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
onUpdate?: () => void;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
export const LinkedStations = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedStationsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные остановки
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedStationsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedStationsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
}: LinkedStationsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "sight";
|
||||
const childResource = "station";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
station_id: selectedItemId,
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
setLinkedItems([...linkedItems, newItem]);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking station:", error);
|
||||
setError("Failed to link station");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting station:", error);
|
||||
setError("Failed to delete station");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked stations:", error);
|
||||
setError("Failed to load linked stations");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all stations:", error);
|
||||
setError("Failed to load available stations");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>{field.label}</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{linkedItems.map((item, index) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Остановки не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить остановку</Typography>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find((item) => item.id === selectedItemId) || null
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Выберите остановку" fullWidth />
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
{String(option.name)}
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Загрузка...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" textAlign="center" py={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedStationsContents = observer(
|
||||
LinkedStationsContentsInner
|
||||
) as typeof LinkedStationsContentsInner;
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./SightListPage";
|
||||
export { LinkedStations } from "./LinkedStations";
|
||||
|
||||
@@ -71,10 +71,8 @@ export const clearBlobAndGLTFCache = async (url: string) => {
|
||||
*/
|
||||
export const clearMediaTransitionCache = async (
|
||||
previousMediaId: string | number | null,
|
||||
newMediaId: string | number | null,
|
||||
newMediaType?: number
|
||||
) => {
|
||||
console.log(newMediaId, newMediaType);
|
||||
// Если переключаемся с/на 3D модель, очищаем весь кеш
|
||||
if (newMediaType === 6 || previousMediaId) {
|
||||
await clearAllGLTFCache();
|
||||
|
||||
1070
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
1070
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
|
||||
export * from "./SelectMediaDialog";
|
||||
export * from "./PreviewMediaDialog";
|
||||
export * from "./UploadMediaDialog";
|
||||
export * from "./ArticleSelectOrCreateDialog";
|
||||
|
||||
@@ -340,55 +340,63 @@ class CreateSightStore {
|
||||
|
||||
createLeftArticle = async () => {
|
||||
/* ... your existing logic to create a new left article (placeholder or DB) ... */
|
||||
const ruName = (this.sight.ru.name || "").trim();
|
||||
const enName = (this.sight.en.name || "").trim();
|
||||
const zhName = (this.sight.zh.name || "").trim();
|
||||
|
||||
// If all names are empty, skip defaulting and use empty headings
|
||||
const hasAnyName = !!(ruName || enName || zhName);
|
||||
|
||||
const response = await languageInstance("ru").post("/article", {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
});
|
||||
const newLeftArticleId = response.data.id;
|
||||
|
||||
await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
});
|
||||
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.sight.left_article = newLeftArticleId; // Store the actual ID
|
||||
this.sight.ru.left = {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
this.sight.en.left = {
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
this.sight.zh.left = {
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
|
||||
articlesStore.articles.ru.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
service_name: "Новая левая статья",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? ruName : "",
|
||||
});
|
||||
articlesStore.articles.en.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
service_name: "New Left Article",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? enName : "",
|
||||
});
|
||||
articlesStore.articles.zh.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
service_name: "新的左侧文章",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? zhName : "",
|
||||
});
|
||||
});
|
||||
return newLeftArticleId;
|
||||
|
||||
@@ -400,16 +400,36 @@ class EditSightStore {
|
||||
};
|
||||
|
||||
createLeftArticle = async () => {
|
||||
const ruName = (this.sight.ru.name || "").trim();
|
||||
const enName = (this.sight.en.name || "").trim();
|
||||
const zhName = (this.sight.zh.name || "").trim();
|
||||
const hasAnyName = !!(ruName || enName || zhName);
|
||||
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: "",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
this.sight.common.left_article = response.data.id;
|
||||
|
||||
this.sight.ru.left.heading = "";
|
||||
this.sight.en.left.heading = "";
|
||||
this.sight.zh.left.heading = "";
|
||||
await languageInstance("en").patch(
|
||||
`/article/${this.sight.common.left_article}`,
|
||||
{
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
}
|
||||
);
|
||||
await languageInstance("zh").patch(
|
||||
`/article/${this.sight.common.left_article}`,
|
||||
{
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
}
|
||||
);
|
||||
|
||||
this.sight.ru.left.heading = hasAnyName ? ruName : "";
|
||||
this.sight.en.left.heading = hasAnyName ? enName : "";
|
||||
this.sight.zh.left.heading = hasAnyName ? zhName : "";
|
||||
this.sight.ru.left.body = "";
|
||||
this.sight.en.left.body = "";
|
||||
this.sight.zh.left.body = "";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
export type Route = {
|
||||
route_name: string;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
@@ -97,6 +98,7 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRouteData = {
|
||||
route_name: "",
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
center_latitude: "",
|
||||
@@ -110,7 +112,7 @@ class RouteStore {
|
||||
route_sys_number: "",
|
||||
scale_max: 0,
|
||||
scale_min: 0,
|
||||
video_preview: "",
|
||||
video_preview: "" as string | undefined,
|
||||
};
|
||||
|
||||
setEditRouteData = (data: any) => {
|
||||
@@ -118,6 +120,9 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
if (!this.editRouteData.video_preview) {
|
||||
delete this.editRouteData.video_preview;
|
||||
}
|
||||
const response = await authInstance.patch(`/route/${id}`, {
|
||||
...this.editRouteData,
|
||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, DragEvent, useEffect } from "react";
|
||||
import React, { useRef, DragEvent } from "react";
|
||||
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
|
||||
import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons
|
||||
import { editSightStore } from "@shared";
|
||||
@@ -27,18 +27,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
tooltipText,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const { setFileToUpload } = editSightStore;
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragOver) {
|
||||
console.log("isDragOver");
|
||||
}
|
||||
}, [isDragOver]);
|
||||
|
||||
// --- Click to select file ---
|
||||
const handleZoneClick = () => {
|
||||
// Trigger the hidden file input click
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
@@ -68,19 +59,16 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Crucial to allow a drop
|
||||
event.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Crucial to allow a drop
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function MediaViewer({
|
||||
// Используем новый cache manager для очистки кеша
|
||||
clearMediaTransitionCache(
|
||||
previousMediaId,
|
||||
media?.id || null,
|
||||
|
||||
media?.media_type
|
||||
);
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
// Компонент предупреждающего окна (перенесен сюда)
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
import { LinkedStations } from "@pages";
|
||||
|
||||
export const InformationTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
@@ -62,7 +62,7 @@ export const InformationTab = observer(
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||
>(null);
|
||||
const { cities } = cityStore;
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {}, [hardcodeType]);
|
||||
@@ -119,16 +119,14 @@ export const InformationTab = observer(
|
||||
updateSightInfo(language, content, common);
|
||||
};
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
|
||||
const executeSave = async () => {
|
||||
await updateSight();
|
||||
toast.success("Достопримечательность сохранена");
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
|
||||
const handleSave = async () => {
|
||||
const isCityMissing = !sight.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
|
||||
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
@@ -139,13 +137,11 @@ export const InformationTab = observer(
|
||||
await executeSave();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmSave = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeSave();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelSave = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
@@ -275,6 +271,16 @@ export const InformationTab = observer(
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: "80%" }}>
|
||||
{sight.common.id !== 0 && (
|
||||
<LinkedStations
|
||||
parentId={sight.common.id}
|
||||
fields={[{ label: "Название", data: "name" }]}
|
||||
type="edit"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -431,7 +437,7 @@ export const InformationTab = observer(
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<Save color="white" size={18} />}
|
||||
onClick={handleSave} // Используем новую функцию-обработчик
|
||||
onClick={handleSave}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
@@ -538,7 +544,6 @@ export const InformationTab = observer(
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
|
||||
@@ -11,6 +11,7 @@ interface VideoPreviewCardProps {
|
||||
onDeleteVideoClick: () => void;
|
||||
onSelectVideoClick: (file?: File) => void;
|
||||
tooltipText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
@@ -20,6 +21,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
onDeleteVideoClick,
|
||||
onSelectVideoClick,
|
||||
tooltipText,
|
||||
className,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -89,7 +91,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
width: "min-content",
|
||||
mx: "auto",
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
|
||||
@@ -127,7 +132,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
</button>
|
||||
)}
|
||||
{videoId ? (
|
||||
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<Box
|
||||
sx={{ position: "relative", width: "100%", height: "100%" }}
|
||||
className={className}
|
||||
>
|
||||
<video
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,56 +3,6 @@ import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
type ManualChunksFn = (id: string, api: { getModuleIds: () => Iterable<string> }) => string | undefined;
|
||||
|
||||
const manualChunks: ManualChunksFn = (id) => {
|
||||
if (id.includes('node_modules')) {
|
||||
|
||||
if (
|
||||
id.includes('three.') ||
|
||||
id.includes('@react-three') ||
|
||||
id.includes('ol/') ||
|
||||
id.includes('mapbox-gl') ||
|
||||
id.includes('@babel/runtime')
|
||||
) {
|
||||
return 'vendor-3d-maps';
|
||||
}
|
||||
|
||||
if (id.includes('codemirror') || id.includes('react-codemirror2')) {
|
||||
return 'vendor-codemirror';
|
||||
}
|
||||
|
||||
if (id.includes('hls.js')) {
|
||||
return 'vendor-hls';
|
||||
}
|
||||
|
||||
if (id.includes('pixi.js')) {
|
||||
return 'vendor-pixijs';
|
||||
}
|
||||
|
||||
if (id.includes('@mui/material') || id.includes('@mui/icons-material') || id.includes('@mui/x-data-grid')) {
|
||||
return 'vendor-mui-core';
|
||||
}
|
||||
|
||||
if (id.includes('/react/') || id.includes('/react-dom/')) {
|
||||
return 'vendor-react-core';
|
||||
}
|
||||
|
||||
if (id.includes('react-router') || id.includes('history')) {
|
||||
return 'vendor-router';
|
||||
}
|
||||
|
||||
return 'vendor-common-remainder';
|
||||
}
|
||||
|
||||
if (id.includes('src/pages/')) {
|
||||
const pathParts = id.split('src/pages/');
|
||||
if (pathParts.length > 1) {
|
||||
return 'page-' + pathParts[1].split('/')[0].toLowerCase();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
@@ -70,16 +20,7 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
build: {
|
||||
chunkSizeWarningLimit: 2000,
|
||||
chunkSizeWarningLimit: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks,
|
||||
|
||||
entryFileNames: `assets/[name]-[hash].js`,
|
||||
chunkFileNames: `assets/[name]-[hash].js`,
|
||||
assetFileNames: `assets/[name]-[hash].[ext]`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}) as UserConfigExport;
|
||||
Reference in New Issue
Block a user