Compare commits
10 Commits
25155a66bc
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 73070fe233 | |||
| 7cf188a55c | |||
| 2a9449ba58 | |||
| 1c097a4ca2 | |||
| 048848faa0 | |||
| 8fe6505249 | |||
| 58abe15ec4 | |||
| 144e7cb00c | |||
| d557664b25 | |||
| bbab6fc46a |
6
.env
6
.env
@@ -1,4 +1,4 @@
|
||||
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'
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
@@ -17,15 +17,14 @@ import {
|
||||
cityStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -56,7 +55,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
@@ -88,13 +87,17 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = selectedMediaId
|
||||
const selectedMedia =
|
||||
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
|
||||
? null
|
||||
: selectedMedia?.id ?? selectedMediaId ?? null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -127,7 +130,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
e.target.value as number,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -151,7 +154,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -168,7 +171,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -184,7 +187,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -193,10 +196,10 @@ export const CarrierCreatePage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setSelectedMediaId(null);
|
||||
@@ -207,7 +210,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
cityStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -123,9 +124,13 @@ export const CarrierEditPage = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCarrierData.logo
|
||||
const selectedMedia =
|
||||
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
|
||||
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||
: null;
|
||||
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
|
||||
? null
|
||||
: (selectedMedia?.id ?? editCarrierData.logo);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
@@ -238,10 +243,10 @@ export const CarrierEditPage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
|
||||
@@ -12,14 +12,18 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
cityStore,
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -72,9 +76,13 @@ export const CityCreatePage = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = createCityData.arms
|
||||
const selectedMedia =
|
||||
createCityData.arms && !isMediaIdEmpty(createCityData.arms)
|
||||
? mediaStore.media.find((m) => m.id === createCityData.arms)
|
||||
: null;
|
||||
const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms)
|
||||
? null
|
||||
: (selectedMedia?.id ?? createCityData.arms);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -135,10 +143,10 @@ export const CityCreatePage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveArmsUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveArmsUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateCityData(
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
@@ -18,16 +17,15 @@ import {
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
CashedCities,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
|
||||
export const CityEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -99,13 +97,17 @@ export const CityEditPage = observer(() => {
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCityData.arms
|
||||
const selectedMedia =
|
||||
editCityData.arms && !isMediaIdEmpty(editCityData.arms)
|
||||
? mediaStore.media.find((m) => m.id === editCityData.arms)
|
||||
: null;
|
||||
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
|
||||
? null
|
||||
: selectedMedia?.id ?? editCityData.arms;
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
@@ -149,7 +151,7 @@ export const CityEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editCityData.country_code,
|
||||
editCityData.arms,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -165,7 +167,7 @@ export const CityEditPage = observer(() => {
|
||||
editCityData[language].name,
|
||||
e.target.value,
|
||||
editCityData.arms,
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -181,17 +183,17 @@ export const CityEditPage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveArmsUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveArmsUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
|
||||
@@ -186,7 +186,7 @@ const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(Array.from(hiddenRoutes))
|
||||
JSON.stringify(Array.from(hiddenRoutes)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save hidden routes:", error);
|
||||
@@ -221,7 +221,7 @@ class MapStore {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(!!val)
|
||||
JSON.stringify(!!val),
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ class MapStore {
|
||||
|
||||
private sortFeatures<T extends ApiStation | ApiSight>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
sortType: SortType,
|
||||
): T[] {
|
||||
const sorted = [...features];
|
||||
switch (sortType) {
|
||||
@@ -324,7 +324,7 @@ class MapStore {
|
||||
return this.sortedStations;
|
||||
}
|
||||
return this.sortedStations.filter(
|
||||
(station) => station.city_id === selectedCityId
|
||||
(station) => station.city_id === selectedCityId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ class MapStore {
|
||||
const response = await languageInstance("ru").get("/route");
|
||||
const routesIds = response.data.map((route: any) => route.id);
|
||||
const routePromises = routesIds.map((id: number) =>
|
||||
languageInstance("ru").get(`/route/${id}`)
|
||||
languageInstance("ru").get(`/route/${id}`),
|
||||
);
|
||||
const routeResponses = await Promise.all(routePromises);
|
||||
this.routes = routeResponses.map((res) => ({
|
||||
@@ -379,7 +379,7 @@ class MapStore {
|
||||
}));
|
||||
|
||||
this.routes = this.routes.sort((a, b) =>
|
||||
a.route_number.localeCompare(b.route_number)
|
||||
a.route_number.localeCompare(b.route_number),
|
||||
);
|
||||
|
||||
await this.preloadRouteStations(routesIds);
|
||||
@@ -391,14 +391,14 @@ class MapStore {
|
||||
const stationPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const stationsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/station`
|
||||
`/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
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -409,7 +409,7 @@ class MapStore {
|
||||
const sightPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const sightsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/sight`
|
||||
`/route/${routeId}/sight`,
|
||||
);
|
||||
const sightIds = sightsResponse.data.map((s: any) => s.id);
|
||||
this.routeSightsCache.set(routeId, sightIds);
|
||||
@@ -493,7 +493,7 @@ class MapStore {
|
||||
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
const carriersInCity = carrierStore.carriers.ru.data.filter(
|
||||
(c: any) => c.city_id === selectedCityStore.selectedCityId
|
||||
(c: any) => c.city_id === selectedCityStore.selectedCityId,
|
||||
);
|
||||
|
||||
if (carriersInCity.length > 0) {
|
||||
@@ -521,7 +521,7 @@ class MapStore {
|
||||
|
||||
if (!carrier_id && selectedCityStore.selectedCityId) {
|
||||
toast.error(
|
||||
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке"
|
||||
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке",
|
||||
);
|
||||
}
|
||||
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
|
||||
@@ -573,7 +573,7 @@ class MapStore {
|
||||
const centerCoords = getCenter(lineGeom.getExtent());
|
||||
const [center_longitude, center_latitude] = toLonLat(
|
||||
centerCoords,
|
||||
"EPSG:3857"
|
||||
"EPSG:3857",
|
||||
);
|
||||
data = {
|
||||
route_number: properties.name,
|
||||
@@ -606,7 +606,7 @@ class MapStore {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find old data for ${featureType} with id ${numericId}`
|
||||
`Could not find old data for ${featureType} with id ${numericId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -626,7 +626,7 @@ class MapStore {
|
||||
|
||||
const response = await languageInstance("ru").patch(
|
||||
`/${featureType}/${numericId}`,
|
||||
requestBody
|
||||
requestBody,
|
||||
);
|
||||
|
||||
const updateStore = (store: any[], updatedItem: any) => {
|
||||
@@ -745,7 +745,7 @@ class MapService {
|
||||
private selectInteraction: Select;
|
||||
private hoveredFeatureId: string | number | null;
|
||||
private boundHandlePointerMove: (
|
||||
event: MapBrowserEvent<PointerEvent>
|
||||
event: MapBrowserEvent<PointerEvent>,
|
||||
) => void;
|
||||
private boundHandlePointerLeave: () => void;
|
||||
private boundHandleContextMenu: (event: MouseEvent) => void;
|
||||
@@ -784,7 +784,7 @@ class MapService {
|
||||
onFeaturesChange: (features: Feature<Geometry>[]) => void,
|
||||
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
|
||||
tooltipElement: HTMLElement,
|
||||
onSelectionChange?: (ids: Set<string | number>) => void
|
||||
onSelectionChange?: (ids: Set<string | number>) => void,
|
||||
) {
|
||||
this.map = null;
|
||||
this.tooltipElement = tooltipElement;
|
||||
@@ -933,7 +933,7 @@ class MapService {
|
||||
style: (featureLike: FeatureLike) => {
|
||||
const clusterFeature = featureLike as Feature<Point>;
|
||||
const featuresInCluster = clusterFeature.get(
|
||||
"features"
|
||||
"features",
|
||||
) as Feature<Point>[];
|
||||
const size = featuresInCluster.length;
|
||||
|
||||
@@ -991,18 +991,18 @@ class MapService {
|
||||
|
||||
this.pointSource.on(
|
||||
"addfeature",
|
||||
this.handleFeatureEvent.bind(this) as any
|
||||
this.handleFeatureEvent.bind(this) as any,
|
||||
);
|
||||
this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
|
||||
this.pointSource.on(
|
||||
"changefeature",
|
||||
this.handleFeatureChange.bind(this) as any
|
||||
this.handleFeatureChange.bind(this) as any,
|
||||
);
|
||||
this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
|
||||
this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
|
||||
this.lineSource.on(
|
||||
"changefeature",
|
||||
this.handleFeatureChange.bind(this) as any
|
||||
this.handleFeatureChange.bind(this) as any,
|
||||
);
|
||||
|
||||
let renderCompleteHandled = false;
|
||||
@@ -1056,7 +1056,7 @@ class MapService {
|
||||
if (center && zoom !== undefined && this.map) {
|
||||
const [lon, lat] = toLonLat(
|
||||
center,
|
||||
this.map.getView().getProjection()
|
||||
this.map.getView().getProjection(),
|
||||
);
|
||||
saveMapPosition({ center: [lon, lat], zoom });
|
||||
}
|
||||
@@ -1068,7 +1068,7 @@ class MapService {
|
||||
if (center && zoom !== undefined && this.map) {
|
||||
const [lon, lat] = toLonLat(
|
||||
center,
|
||||
this.map.getView().getProjection()
|
||||
this.map.getView().getProjection(),
|
||||
);
|
||||
saveMapPosition({ center: [lon, lat], zoom });
|
||||
}
|
||||
@@ -1189,7 +1189,7 @@ class MapService {
|
||||
const feature = this.map?.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter, hitTolerance: 5 }
|
||||
{ layerFilter, hitTolerance: 5 },
|
||||
);
|
||||
|
||||
if (!feature) return;
|
||||
@@ -1227,7 +1227,7 @@ class MapService {
|
||||
}
|
||||
|
||||
const newCoordinates = coordinates.filter(
|
||||
(_, index) => index !== closestIndex
|
||||
(_, index) => index !== closestIndex,
|
||||
);
|
||||
lineString.setCoordinates(newCoordinates);
|
||||
this.saveModifiedFeature(feature);
|
||||
@@ -1270,7 +1270,7 @@ class MapService {
|
||||
selected.add(f.getId()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.setSelectedIds(selected);
|
||||
@@ -1417,7 +1417,7 @@ class MapService {
|
||||
public loadFeaturesFromApi(
|
||||
_apiStations: typeof mapStore.stations,
|
||||
_apiRoutes: typeof mapStore.routes,
|
||||
_apiSights: typeof mapStore.sights
|
||||
_apiSights: typeof mapStore.sights,
|
||||
): void {
|
||||
if (!this.map) return;
|
||||
|
||||
@@ -1450,8 +1450,8 @@ class MapService {
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
projection,
|
||||
),
|
||||
);
|
||||
const feature = new Feature({ geometry: point, name: station.name });
|
||||
feature.setId(`station-${station.id}`);
|
||||
@@ -1462,7 +1462,7 @@ class MapService {
|
||||
filteredSights.forEach((sight) => {
|
||||
if (sight.longitude == null || sight.latitude == null) return;
|
||||
const point = new Point(
|
||||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
|
||||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection),
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
@@ -1482,7 +1482,7 @@ class MapService {
|
||||
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)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
);
|
||||
|
||||
if (coordinates.length === 0) return;
|
||||
@@ -1568,7 +1568,7 @@ class MapService {
|
||||
|
||||
public startDrawing(
|
||||
type: "Point" | "LineString",
|
||||
featureType: FeatureType
|
||||
featureType: FeatureType,
|
||||
): void {
|
||||
if (!this.map) return;
|
||||
|
||||
@@ -1732,7 +1732,7 @@ class MapService {
|
||||
this.map.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter, hitTolerance: 5 }
|
||||
{ layerFilter, hitTolerance: 5 },
|
||||
);
|
||||
|
||||
let finalFeature: Feature<Geometry> | null = null;
|
||||
@@ -1807,7 +1807,7 @@ class MapService {
|
||||
|
||||
public deleteFeature(
|
||||
featureId: string | number | undefined,
|
||||
recourse: string
|
||||
recourse: string,
|
||||
): void {
|
||||
if (featureId === undefined) return;
|
||||
|
||||
@@ -1863,7 +1863,7 @@ class MapService {
|
||||
const lineFeature = this.lineSource.getFeatureById(id);
|
||||
if (lineFeature)
|
||||
this.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
lineFeature as Feature<LineString>,
|
||||
);
|
||||
const pointFeature = this.pointSource.getFeatureById(id);
|
||||
if (pointFeature)
|
||||
@@ -1890,11 +1890,11 @@ class MapService {
|
||||
if (targetEl instanceof HTMLElement) {
|
||||
targetEl.removeEventListener(
|
||||
"contextmenu",
|
||||
this.boundHandleContextMenu
|
||||
this.boundHandleContextMenu,
|
||||
);
|
||||
targetEl.removeEventListener(
|
||||
"pointerleave",
|
||||
this.boundHandlePointerLeave
|
||||
this.boundHandlePointerLeave,
|
||||
);
|
||||
}
|
||||
this.map.un("pointermove", this.boundHandlePointerMove as any);
|
||||
@@ -1907,7 +1907,7 @@ class MapService {
|
||||
}
|
||||
|
||||
private handleFeatureEvent(
|
||||
event: VectorSourceEvent<Feature<Geometry>>
|
||||
event: VectorSourceEvent<Feature<Geometry>>,
|
||||
): void {
|
||||
if (!event.feature) return;
|
||||
const feature = event.feature;
|
||||
@@ -1918,7 +1918,7 @@ class MapService {
|
||||
}
|
||||
|
||||
private handleFeatureChange(
|
||||
event: VectorSourceEvent<Feature<Geometry>>
|
||||
event: VectorSourceEvent<Feature<Geometry>>,
|
||||
): void {
|
||||
if (!event.feature) return;
|
||||
this.updateFeaturesInReact();
|
||||
@@ -1956,7 +1956,7 @@ class MapService {
|
||||
});
|
||||
|
||||
this.modifyInteraction.setActive(
|
||||
this.selectInteraction.getFeatures().getLength() > 0
|
||||
this.selectInteraction.getFeatures().getLength() > 0,
|
||||
);
|
||||
this.clusterLayer.changed();
|
||||
this.routeLayer.changed();
|
||||
@@ -2026,7 +2026,7 @@ class MapService {
|
||||
if (typeof featureId === "number" || !String(featureId).includes("-")) {
|
||||
console.warn(
|
||||
"Skipping save for feature with non-standard ID:",
|
||||
featureId
|
||||
featureId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2074,7 +2074,7 @@ class MapService {
|
||||
try {
|
||||
const createdFeatureData = await mapStore.createFeature(
|
||||
featureType,
|
||||
featureGeoJSON
|
||||
featureGeoJSON,
|
||||
);
|
||||
|
||||
const newFeatureId = `${featureType}-${createdFeatureData.id}`;
|
||||
@@ -2093,8 +2093,8 @@ class MapService {
|
||||
|
||||
const lineGeom = new LineString(
|
||||
routeData.path.map((c) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
),
|
||||
);
|
||||
feature.setGeometry(lineGeom);
|
||||
} else {
|
||||
@@ -2247,7 +2247,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const actualFeatures = useMemo(
|
||||
() => mapFeatures.filter((f) => !f.get("isProxy")),
|
||||
[mapFeatures]
|
||||
[mapFeatures],
|
||||
);
|
||||
|
||||
const allFeatures = useMemo(() => {
|
||||
@@ -2257,8 +2257,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
"EPSG:3857"
|
||||
)
|
||||
"EPSG:3857",
|
||||
),
|
||||
),
|
||||
name: station.name,
|
||||
description: station.description || "",
|
||||
@@ -2275,8 +2275,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[sight.longitude, sight.latitude],
|
||||
"EPSG:4326",
|
||||
"EPSG:3857"
|
||||
)
|
||||
"EPSG:3857",
|
||||
),
|
||||
),
|
||||
name: sight.name,
|
||||
description: sight.description,
|
||||
@@ -2320,7 +2320,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
(f.get("routeNumber") as string) || "",
|
||||
];
|
||||
return candidates.some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery)
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
});
|
||||
}, [allFeatures, searchQuery]);
|
||||
@@ -2343,7 +2343,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
mapService.selectFeature(id);
|
||||
}
|
||||
},
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
[mapService, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -2353,7 +2353,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
mapService.deleteFeature(id, resource);
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService],
|
||||
);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
@@ -2365,14 +2365,14 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
setSelectedIds(newSet);
|
||||
mapService.setSelectedIds(newSet);
|
||||
},
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
[mapService, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
if (!mapService || selectedIds.size === 0) return;
|
||||
if (
|
||||
window.confirm(
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`,
|
||||
)
|
||||
) {
|
||||
mapService.deleteMultipleFeatures(Array.from(selectedIds));
|
||||
@@ -2386,7 +2386,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
if (!featureType || !numericId) return;
|
||||
navigate(`/${featureType}/${numericId}/edit`);
|
||||
},
|
||||
[navigate]
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleHideRoute = useCallback(
|
||||
@@ -2413,7 +2413,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
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)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
);
|
||||
|
||||
if (coordinates.length > 0) {
|
||||
@@ -2435,7 +2435,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
|
||||
);
|
||||
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
@@ -2443,12 +2443,12 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
stationsInVisibleRoutes.add(id),
|
||||
);
|
||||
});
|
||||
|
||||
const stationsToShow = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
(id: number) => !stationsInVisibleRoutes.has(id),
|
||||
);
|
||||
|
||||
for (const stationId of stationsToShow) {
|
||||
@@ -2459,8 +2459,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
projection,
|
||||
),
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
@@ -2470,7 +2470,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
feature.set("featureType", "station");
|
||||
|
||||
const existingFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${station.id}`
|
||||
`station-${station.id}`,
|
||||
);
|
||||
if (!existingFeature) {
|
||||
mapService.pointSource.addFeature(feature);
|
||||
@@ -2487,7 +2487,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
|
||||
);
|
||||
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
@@ -2495,21 +2495,21 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
stationsInVisibleRoutes.add(id),
|
||||
);
|
||||
});
|
||||
|
||||
const stationsToHide = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
(id: number) => !stationsInVisibleRoutes.has(id),
|
||||
);
|
||||
|
||||
stationsToHide.forEach((stationId: number) => {
|
||||
const pointFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${stationId}`
|
||||
`station-${stationId}`,
|
||||
);
|
||||
if (pointFeature) {
|
||||
mapService.pointSource.removeFeature(
|
||||
pointFeature as Feature<Point>
|
||||
pointFeature as Feature<Point>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2517,7 +2517,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const lineFeature = mapService.lineSource.getFeatureById(routeId);
|
||||
if (lineFeature) {
|
||||
mapService.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
lineFeature as Feature<LineString>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2529,31 +2529,31 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[handleHideRoute] Error toggling route visibility:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
toast.error("Ошибка при изменении видимости маршрута");
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService],
|
||||
);
|
||||
|
||||
const sortFeaturesByType = <T extends Feature<Geometry>>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
sortType: SortType,
|
||||
): T[] => {
|
||||
const sorted = [...features];
|
||||
switch (sortType) {
|
||||
case "name_asc":
|
||||
return sorted.sort((a, b) =>
|
||||
((a.get("name") as string) || "").localeCompare(
|
||||
(b.get("name") as string) || ""
|
||||
)
|
||||
(b.get("name") as string) || "",
|
||||
),
|
||||
);
|
||||
case "name_desc":
|
||||
return sorted.sort((a, b) =>
|
||||
((b.get("name") as string) || "").localeCompare(
|
||||
(a.get("name") as string) || ""
|
||||
)
|
||||
(a.get("name") as string) || "",
|
||||
),
|
||||
);
|
||||
case "created_asc":
|
||||
return sorted.sort((a, b) => {
|
||||
@@ -2609,13 +2609,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
};
|
||||
|
||||
const stations = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "station"
|
||||
(f) => f.get("featureType") === "station",
|
||||
);
|
||||
const lines = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "route"
|
||||
(f) => f.get("featureType") === "route",
|
||||
);
|
||||
const sights = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "sight"
|
||||
(f) => f.get("featureType") === "sight",
|
||||
);
|
||||
|
||||
const sortedStations = sortFeaturesByType(stations, stationSort);
|
||||
@@ -2624,7 +2624,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const renderFeatureList = (
|
||||
features: Feature<Geometry>[],
|
||||
featureType: "station" | "route" | "sight",
|
||||
IconComponent: React.ElementType
|
||||
IconComponent: React.ElementType,
|
||||
) => (
|
||||
<div className="space-y-1 pr-1">
|
||||
{features.length > 0 ? (
|
||||
@@ -2897,7 +2897,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
}`}
|
||||
onClick={() =>
|
||||
mapStore.setHideSightsByHiddenRoutes(
|
||||
!mapStore.hideSightsByHiddenRoutes
|
||||
!mapStore.hideSightsByHiddenRoutes,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -2992,7 +2992,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -3009,7 +3009,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const MapPage: React.FC = observer(() => {
|
||||
@@ -3025,7 +3025,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
|
||||
useState<Feature<Geometry> | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
|
||||
new Set()
|
||||
new Set(),
|
||||
);
|
||||
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
@@ -3037,7 +3037,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
|
||||
const handleFeaturesChange = useCallback(
|
||||
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFeatureSelectForSidebar = useCallback(
|
||||
@@ -3059,7 +3059,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3080,7 +3080,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapService.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load initial map data:", e);
|
||||
@@ -3099,7 +3099,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
handleFeaturesChange,
|
||||
handleFeatureSelectForSidebar,
|
||||
tooltipRef.current,
|
||||
setSelectedIds
|
||||
setSelectedIds,
|
||||
);
|
||||
setMapServiceInstance(service);
|
||||
|
||||
@@ -3110,7 +3110,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
loadInitialData(service);
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
|
||||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`,
|
||||
);
|
||||
setIsMapLoading(false);
|
||||
setIsDataLoading(false);
|
||||
@@ -3203,7 +3203,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, mapServiceInstance, isDataLoading]);
|
||||
@@ -3216,7 +3216,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
@@ -3232,7 +3232,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
selectedFeatureForSidebar !== null || selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
|
||||
<div className="-mb-4 flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
|
||||
<div className="relative flex-grow flex">
|
||||
<div
|
||||
ref={mapRef}
|
||||
@@ -3279,35 +3279,87 @@ export const MapPage: React.FC = observer(() => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
|
||||
title="Помощь по клавишам"
|
||||
>
|
||||
<InfoIcon size={20} />
|
||||
</button>
|
||||
|
||||
{showHelp && (
|
||||
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
|
||||
<h4 className="font-bold mb-2">Горячие клавиши:</h4>
|
||||
<ul className="text-sm space-y-2">
|
||||
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-md max-h-[30vh] overflow-y-auto scrollbar-visible">
|
||||
<h4 className="font-bold mb-2">Управление картой</h4>
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Перемещение и масштаб:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Колесо мыши — приблизить / отдалить.</li>
|
||||
<li>
|
||||
Средняя кнопка мыши (колесо зажато) — перетаскивание карты.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Выделение объектов:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Одинарный клик по объекту — выделить и центрировать.</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Ctrl
|
||||
</span>{" "}
|
||||
+ клик — добавить / убрать объект из выделения.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Shift
|
||||
</span>{" "}
|
||||
- Режим выделения (лассо)
|
||||
— временно включить режим лассо (выделение области).
|
||||
</li>
|
||||
<li>Клик по пустому месту карты — снять выделение.</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Ctrl + клик
|
||||
Esc
|
||||
</span>{" "}
|
||||
- Добавить/убрать из выделения
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
|
||||
- Отменить выделение
|
||||
— снять выделение всех объектов.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">
|
||||
Рисование и редактирование:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
Кнопки в верхней панели — выбор режима: редактирование,
|
||||
добавление остановки, достопримечательности или маршрута.
|
||||
</li>
|
||||
<li>
|
||||
При рисовании маршрута: правый клик — завершить линию.
|
||||
</li>
|
||||
<li>
|
||||
В режиме редактирования: перетаскивайте точки маршрута для
|
||||
изменения траектории.
|
||||
</li>
|
||||
<li>
|
||||
Двойной клик по внутренней точке маршрута — удалить эту
|
||||
точку (при наличии не менее 2 точек).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Боковая панель:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Клик по строке в списке — перейти к объекту на карте.</li>
|
||||
<li>
|
||||
Иконка карандаша — открыть объект в режиме редактирования.
|
||||
</li>
|
||||
<li>
|
||||
Иконка карты у маршрутов — открыть предпросмотр маршрута.
|
||||
</li>
|
||||
<li>
|
||||
Иконка глаза у маршрутов — скрыть / показать маршрут и
|
||||
связанные остановки.
|
||||
</li>
|
||||
<li>Иконка корзины — удалить объект.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
|
||||
@@ -3316,6 +3368,14 @@ export const MapPage: React.FC = observer(() => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
|
||||
title="Помощь по управлению картой"
|
||||
>
|
||||
<InfoIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{showContent && (
|
||||
<MapSightbar
|
||||
|
||||
@@ -162,7 +162,10 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const query = searchQuery.toLowerCase();
|
||||
const name = String(item.name || "").toLowerCase();
|
||||
const description = String(item.description || "").toLowerCase();
|
||||
return name.includes(query) || description.includes(query);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -206,12 +209,11 @@ const LinkedItemsContentsInner = <
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
stations: reorderedItems.map((item) => {
|
||||
const stationData: any = { id: item.id };
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
if (transfers) {
|
||||
stationData.transfers = transfers;
|
||||
}
|
||||
return stationData;
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -263,24 +265,23 @@ const LinkedItemsContentsInner = <
|
||||
const requestData = {
|
||||
stations: insertAtPosition(
|
||||
linkedItems.map((item) => {
|
||||
const stationData: any = { id: item.id };
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
if (transfers) {
|
||||
stationData.transfers = transfers;
|
||||
}
|
||||
return stationData;
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
position,
|
||||
(() => {
|
||||
const newStationData: any = { id: selectedItemId };
|
||||
if (!selectedItem) return { id: selectedItemId };
|
||||
const transfers = getStationTransfers(
|
||||
selectedItemId,
|
||||
selectedItem?.transfers
|
||||
selectedItem.transfers
|
||||
);
|
||||
if (transfers) {
|
||||
newStationData.transfers = transfers;
|
||||
}
|
||||
return newStationData;
|
||||
return {
|
||||
...selectedItem,
|
||||
transfers: transfers || selectedItem.transfers,
|
||||
};
|
||||
})()
|
||||
),
|
||||
};
|
||||
@@ -365,22 +366,21 @@ const LinkedItemsContentsInner = <
|
||||
setIsLinkingBulk(true);
|
||||
const selectedStations = Array.from(selectedItems).map((id) => {
|
||||
const item = allItems.find((item) => item.id === id);
|
||||
const stationData: any = { id };
|
||||
const transfers = getStationTransfers(id, item?.transfers);
|
||||
if (transfers) {
|
||||
stationData.transfers = transfers;
|
||||
}
|
||||
return stationData;
|
||||
if (!item) return { id };
|
||||
const transfers = getStationTransfers(id, item.transfers);
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
});
|
||||
const requestData = {
|
||||
stations: [
|
||||
...linkedItems.map((item) => {
|
||||
const stationData: any = { id: item.id };
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
if (transfers) {
|
||||
stationData.transfers = transfers;
|
||||
}
|
||||
return stationData;
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
...selectedStations,
|
||||
],
|
||||
|
||||
@@ -13,22 +13,30 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
} from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||
import {
|
||||
carrierStore,
|
||||
articlesStore,
|
||||
routeStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import type { Route } from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -45,18 +53,27 @@ export const RouteCreatePage = observer(() => {
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||
const [icon, setIcon] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
|
||||
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
|
||||
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
|
||||
const [previewIconId, setPreviewIconId] = useState("");
|
||||
const [activeIconMenuType, setActiveIconMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
articlesStore.getArticleList();
|
||||
mediaStore.getMedia();
|
||||
}, [language]);
|
||||
|
||||
const filteredCarriers = useMemo(() => {
|
||||
@@ -150,6 +167,23 @@ export const RouteCreatePage = observer(() => {
|
||||
setIsVideoPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleIconSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setIcon(media.id);
|
||||
setIsSelectIconDialogOpen(false);
|
||||
};
|
||||
|
||||
const selectedIconMedia =
|
||||
icon && !isMediaIdEmpty(icon)
|
||||
? mediaStore.media.find((m) => m.id === icon)
|
||||
: null;
|
||||
const effectiveIconUrl = isMediaIdEmpty(icon) ? null : selectedIconMedia?.id ?? icon;
|
||||
const effectiveVideoId = isMediaIdEmpty(videoPreview) ? null : videoPreview;
|
||||
|
||||
const handleCreateRoute = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -243,8 +277,8 @@ export const RouteCreatePage = observer(() => {
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
path,
|
||||
video_preview:
|
||||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||
video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
|
||||
icon: !isMediaIdEmpty(icon) ? icon : undefined,
|
||||
};
|
||||
|
||||
if (governor_appeal !== undefined) {
|
||||
@@ -403,16 +437,41 @@ export const RouteCreatePage = observer(() => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="w-full flex justify-center gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-4 max-w-[300px]">
|
||||
<ImageUploadCard
|
||||
title="Иконка маршрута"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewIconOpen(true);
|
||||
setPreviewIconId(selectedIconMedia?.id ?? icon ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIcon("");
|
||||
setActiveIconMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveIconMenuType("image");
|
||||
setIsSelectIconDialogOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadIconDialogOpen(true);
|
||||
setActiveIconMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 max-w-[300px]">
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={videoPreview}
|
||||
videoId={effectiveVideoId}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
setVideoPreview("");
|
||||
}}
|
||||
onDeleteVideoClick={() => setVideoPreview("")}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
@@ -522,7 +581,7 @@ export const RouteCreatePage = observer(() => {
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
{effectiveVideoId && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
@@ -534,7 +593,7 @@ export const RouteCreatePage = observer(() => {
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: videoPreview,
|
||||
id: effectiveVideoId,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
@@ -560,6 +619,28 @@ export const RouteCreatePage = observer(() => {
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectIconDialogOpen}
|
||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||
onSelectMedia={handleIconSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadIconDialogOpen}
|
||||
onClose={() => setIsUploadIconDialogOpen(false)}
|
||||
contextObjectName={routeName || "Маршрут"}
|
||||
contextType="route"
|
||||
afterUpload={handleIconSelect}
|
||||
hardcodeType={activeIconMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewIconOpen}
|
||||
onClose={() => setIsPreviewIconOpen(false)}
|
||||
mediaId={previewIconId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,24 +13,31 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
carrierStore,
|
||||
articlesStore,
|
||||
routeStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
stationsStore,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
import { LinkedItems } from "../LinekedStations";
|
||||
|
||||
export const RouteEditPage = observer(() => {
|
||||
@@ -44,6 +51,14 @@ export const RouteEditPage = observer(() => {
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
|
||||
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
|
||||
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
|
||||
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
|
||||
const [previewIconId, setPreviewIconId] = useState("");
|
||||
const [activeIconMenuType, setActiveIconMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
@@ -71,10 +86,32 @@ export const RouteEditPage = observer(() => {
|
||||
await carrierStore.getCarriers(language);
|
||||
await stationsStore.getStations();
|
||||
await articlesStore.getArticleList();
|
||||
await mediaStore.getMedia();
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
|
||||
const handleIconSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({ icon: media.id });
|
||||
setIsSelectIconDialogOpen(false);
|
||||
};
|
||||
|
||||
const selectedIconMedia =
|
||||
editRouteData.icon && !isMediaIdEmpty(editRouteData.icon)
|
||||
? mediaStore.media.find((m) => m.id === editRouteData.icon)
|
||||
: null;
|
||||
const effectiveIconUrl = isMediaIdEmpty(editRouteData.icon)
|
||||
? null
|
||||
: (selectedIconMedia?.id ?? editRouteData.icon);
|
||||
const effectiveVideoId = isMediaIdEmpty(editRouteData.video_preview)
|
||||
? null
|
||||
: editRouteData.video_preview;
|
||||
|
||||
useEffect(() => {
|
||||
if (editRouteData.path && editRouteData.path.length > 0) {
|
||||
const formattedPath = editRouteData.path
|
||||
@@ -552,9 +589,33 @@ export const RouteEditPage = observer(() => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="w-full flex justify-center gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-4 max-w-[300px]">
|
||||
<ImageUploadCard
|
||||
title="Иконка маршрута"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewIconOpen(true);
|
||||
setPreviewIconId(
|
||||
selectedIconMedia?.id ?? editRouteData.icon ?? ""
|
||||
);
|
||||
}}
|
||||
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
|
||||
onSelectFileClick={() => {
|
||||
setActiveIconMenuType("image");
|
||||
setIsSelectIconDialogOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadIconDialogOpen(true);
|
||||
setActiveIconMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 max-w-[300px]">
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={editRouteData.video_preview}
|
||||
videoId={effectiveVideoId}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
@@ -562,6 +623,8 @@ export const RouteEditPage = observer(() => {
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<LinkedItems
|
||||
@@ -621,10 +684,10 @@ export const RouteEditPage = observer(() => {
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
{editRouteData.video_preview && (
|
||||
{effectiveVideoId && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
id: effectiveVideoId,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
@@ -648,6 +711,38 @@ export const RouteEditPage = observer(() => {
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectIconDialogOpen}
|
||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||
onSelectMedia={handleIconSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadIconDialogOpen}
|
||||
onClose={() => setIsUploadIconDialogOpen(false)}
|
||||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||
contextType="route"
|
||||
afterUpload={handleIconSelect}
|
||||
hardcodeType={activeIconMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewIconOpen}
|
||||
onClose={() => setIsPreviewIconOpen(false)}
|
||||
mediaId={previewIconId}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteIconModalOpen}
|
||||
onDelete={() => {
|
||||
routeStore.setEditRouteData({ icon: "" });
|
||||
setIsDeleteIconModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteIconModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authInstance } from "@shared";
|
||||
import { authInstance, isMediaIdEmpty } from "@shared";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import LanguageSelector from "./web-gl/LanguageSelector";
|
||||
|
||||
@@ -106,7 +106,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{carrierThumbnail && (
|
||||
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
@@ -143,7 +143,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
justifyContent="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{carrierLogo && (
|
||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
|
||||
@@ -169,7 +169,7 @@ export const MapDataProvider = observer(
|
||||
}
|
||||
|
||||
function setIconSize(size: number) {
|
||||
const clamped = Math.max(50, Math.min(300, size));
|
||||
const clamped = Math.max(1, Math.min(300, size));
|
||||
setRouteChanges((prev) => {
|
||||
if (prev.icon_size === clamped) {
|
||||
return prev;
|
||||
@@ -179,7 +179,7 @@ export const MapDataProvider = observer(
|
||||
}
|
||||
|
||||
function setFontSize(size: number) {
|
||||
const clamped = Math.max(50, Math.min(300, size));
|
||||
const clamped = Math.max(1, Math.min(300, size));
|
||||
setRouteChanges((prev) => {
|
||||
if (prev.font_size === clamped) {
|
||||
return prev;
|
||||
|
||||
@@ -101,7 +101,7 @@ export function RightSidebar() {
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
||||
const clamped = Math.max(1, Math.min(300, Math.round(value)));
|
||||
setIconSize(clamped);
|
||||
updateIconSize(clamped);
|
||||
};
|
||||
@@ -110,7 +110,7 @@ export function RightSidebar() {
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
||||
const clamped = Math.max(1, Math.min(300, Math.round(value)));
|
||||
setFontSize(clamped);
|
||||
updateFontSize(clamped);
|
||||
};
|
||||
@@ -307,60 +307,58 @@ export function RightSidebar() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Размер иконок: {iconSize}%
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер иконок (%)"
|
||||
variant="filled"
|
||||
value={iconSize}
|
||||
onChange={(_, value) => {
|
||||
if (typeof value === "number") {
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
handleIconSizeChange(value);
|
||||
}
|
||||
}}
|
||||
min={50}
|
||||
max={300}
|
||||
step={1}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 300,
|
||||
step: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Размер шрифта: {fontSize}%
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер шрифта (%)"
|
||||
variant="filled"
|
||||
value={fontSize}
|
||||
onChange={(_, value) => {
|
||||
if (typeof value === "number") {
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
handleFontSizeChange(value);
|
||||
}
|
||||
}}
|
||||
min={50}
|
||||
max={300}
|
||||
step={1}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 300,
|
||||
step: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface StationData {
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
icon?: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { coordinatesToLocal, localToCoordinates } from "../utils";
|
||||
import { BACKGROUND_COLOR, SCALE_FACTOR, UP_SCALE } from "../Constants";
|
||||
import { languageStore } from "@shared";
|
||||
import { SightData } from "../types";
|
||||
import { isMediaIdEmpty } from "../../../../shared/lib/index";
|
||||
|
||||
const SIGHT_ICON_URL = "/sight_icon.svg";
|
||||
|
||||
@@ -1960,6 +1961,15 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
? { right: 0, transform: "none" }
|
||||
: { left: "50%", transform: "translateX(-50%)" };
|
||||
|
||||
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
||||
const iconUrl = isMediaIdEmptyResult
|
||||
? null
|
||||
: `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
station.icon
|
||||
}/download?token=${localStorage.getItem("token") ?? ""}`;
|
||||
|
||||
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
||||
|
||||
const secondaryLineHeight = 1.2;
|
||||
const secondaryHeight = showSecondary
|
||||
? secondaryFontSize * secondaryLineHeight
|
||||
@@ -2019,10 +2029,24 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: iconUrl ? 6 : 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: iconSizePx,
|
||||
height: iconSizePx,
|
||||
flexShrink: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SnapshotCreatePage = observer(() => {
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
@@ -53,7 +53,7 @@ export const SnapshotCreatePage = observer(() => {
|
||||
}
|
||||
|
||||
if (snapshotStore.snapshotStatus?.Status === "done") {
|
||||
toast.success("Снапшот успешно создан");
|
||||
toast.success("Экспорт медиа успешно создан");
|
||||
|
||||
runInAction(() => {
|
||||
snapshotStore.snapshotStatus = null;
|
||||
@@ -63,7 +63,7 @@ export const SnapshotCreatePage = observer(() => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании снапшота");
|
||||
toast.error("Ошибка при создании экспорта медиа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ export const SnapshotListPage = observer(() => {
|
||||
fetchSnapshots();
|
||||
}, [language]);
|
||||
|
||||
const formatCreationTime = (isoString: string | undefined) => {
|
||||
if (!isoString) return "";
|
||||
const [datePart, timePartWithMs] = isoString.split("T");
|
||||
if (!datePart || !timePartWithMs) return isoString;
|
||||
const timePart = timePartWithMs.split(".")[0];
|
||||
return `${datePart} - ${timePart}`;
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
@@ -41,7 +49,14 @@ export const SnapshotListPage = observer(() => {
|
||||
headerName: "Родитель",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
{
|
||||
field: "created_at",
|
||||
headerName: "Дата создания",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return <div>{params.value ? params.value : "-"}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
@@ -79,14 +94,15 @@ export const SnapshotListPage = observer(() => {
|
||||
id: snapshot.ID,
|
||||
name: snapshot.Name,
|
||||
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||
created_at: formatCreationTime(snapshot.CreationTime),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl ">Снапшоты</h1>
|
||||
<CreateButton label="Создать снапшот" path="/snapshot/create" />
|
||||
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
||||
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
|
||||
</div>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -99,7 +115,11 @@ export const SnapshotListPage = observer(() => {
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет экспортов медиа"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
SaveWithoutCityAgree,
|
||||
} from "@widgets";
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -35,6 +42,13 @@ export const StationCreatePage = observer(() => {
|
||||
const { cities, getCities } = cityStore;
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
@@ -96,8 +110,27 @@ export const StationCreatePage = observer(() => {
|
||||
};
|
||||
|
||||
fetchCities();
|
||||
mediaStore.getMedia();
|
||||
}, []);
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setCreateCommonData({ icon: media.id });
|
||||
};
|
||||
|
||||
const selectedMedia =
|
||||
createStationData.common.icon &&
|
||||
!isMediaIdEmpty(createStationData.common.icon)
|
||||
? mediaStore.media.find((m) => m.id === createStationData.common.icon)
|
||||
: null;
|
||||
const effectiveIconUrl = isMediaIdEmpty(createStationData.common.icon)
|
||||
? null
|
||||
: selectedMedia?.id ?? createStationData.common.icon;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||
setCreateCommonData({
|
||||
@@ -108,7 +141,7 @@ export const StationCreatePage = observer(() => {
|
||||
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<Box className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@@ -213,6 +246,30 @@ export const StationCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Иконка остановки"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(effectiveIconUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateCommonData({ icon: "" });
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
@@ -229,6 +286,28 @@ export const StationCreatePage = observer(() => {
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createStationData[language].name || "Остановка"}
|
||||
contextType="station"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
@@ -237,6 +316,6 @@ export const StationCreatePage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -9,20 +8,28 @@ import {
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
SaveWithoutCityAgree,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -39,6 +46,14 @@ export const StationEditPage = observer(() => {
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
@@ -95,6 +110,23 @@ export const StationEditPage = observer(() => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setEditCommonData({ icon: media.id });
|
||||
};
|
||||
|
||||
const selectedMedia =
|
||||
editStationData.common.icon && !isMediaIdEmpty(editStationData.common.icon)
|
||||
? mediaStore.media.find((m) => m.id === editStationData.common.icon)
|
||||
: null;
|
||||
const effectiveIconUrl = isMediaIdEmpty(editStationData.common.icon)
|
||||
? null
|
||||
: selectedMedia?.id ?? editStationData.common.icon;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAndSetStationData = async () => {
|
||||
if (!id) {
|
||||
@@ -109,6 +141,7 @@ export const StationEditPage = observer(() => {
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
await mediaStore.getMedia();
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
@@ -133,7 +166,7 @@ export const StationEditPage = observer(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<Box className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -239,6 +272,29 @@ export const StationEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Иконка остановки"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(effectiveIconUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteIconModalOpen(true);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
@@ -262,6 +318,38 @@ export const StationEditPage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editStationData[language].name || "Остановка"}
|
||||
contextType="station"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteIconModalOpen}
|
||||
onDelete={() => {
|
||||
setEditCommonData({ icon: "" });
|
||||
setIsDeleteIconModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteIconModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
@@ -271,6 +359,6 @@ export const StationEditPage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,17 +6,35 @@ import {
|
||||
FormControlLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
userStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard } from "@widgets";
|
||||
|
||||
export const UserCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { createUserData, setCreateUserData, createUser } = userStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mediaStore.getMedia();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
@@ -31,6 +49,29 @@ export const UserCreatePage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false,
|
||||
media.id
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia =
|
||||
createUserData.icon && !isMediaIdEmpty(createUserData.icon)
|
||||
? mediaStore.media.find((m) => m.id === createUserData.icon)
|
||||
: null;
|
||||
const effectiveIconUrl = isMediaIdEmpty(createUserData.icon)
|
||||
? null
|
||||
: selectedMedia?.id ?? createUserData.icon ?? null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -54,7 +95,8 @@ export const UserCreatePage = observer(() => {
|
||||
e.target.value,
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -69,7 +111,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
e.target.value,
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -84,7 +127,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
e.target.value,
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -99,7 +143,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
e.target.checked
|
||||
e.target.checked,
|
||||
createUserData.icon
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -108,6 +153,36 @@ export const UserCreatePage = observer(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Аватар"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(effectiveIconUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false,
|
||||
""
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
@@ -124,6 +199,28 @@ export const UserCreatePage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createUserData.name || "Пользователь"}
|
||||
contextType="user"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,12 +7,21 @@ import {
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore, languageStore, LoadingSpinner } from "@shared";
|
||||
import {
|
||||
userStore,
|
||||
languageStore,
|
||||
LoadingSpinner,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ImageUploadCard, DeleteModal } from "@widgets";
|
||||
|
||||
export const UserEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -22,8 +31,16 @@ export const UserEditPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { editUserData, editUser, getUser, setEditUserData } = userStore;
|
||||
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
@@ -40,19 +57,38 @@ export const UserEditPage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false,
|
||||
media.id
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await mediaStore.getMedia();
|
||||
const data = await getUser(Number(id));
|
||||
|
||||
if (data) {
|
||||
setEditUserData(
|
||||
data?.name || "",
|
||||
data?.email || "",
|
||||
data?.password || "",
|
||||
data?.is_admin || false
|
||||
data.name || "",
|
||||
data.email || "",
|
||||
data.password || "",
|
||||
data.is_admin || false,
|
||||
data.icon || ""
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
@@ -62,6 +98,14 @@ export const UserEditPage = observer(() => {
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const selectedMedia =
|
||||
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
|
||||
? mediaStore.media.find((m) => m.id === editUserData.icon)
|
||||
: null;
|
||||
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
|
||||
? null
|
||||
: selectedMedia?.id ?? editUserData.icon ?? null;
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
@@ -100,7 +144,8 @@ export const UserEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -114,7 +159,8 @@ export const UserEditPage = observer(() => {
|
||||
editUserData.name || "",
|
||||
e.target.value,
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -122,14 +168,15 @@ export const UserEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Пароль"
|
||||
placeholder="Оставить пустым, чтобы не менять"
|
||||
value={editUserData.password || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
e.target.value,
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -142,7 +189,8 @@ export const UserEditPage = observer(() => {
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
e.target.checked
|
||||
e.target.checked,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -150,6 +198,27 @@ export const UserEditPage = observer(() => {
|
||||
label="Администратор"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
||||
<ImageUploadCard
|
||||
title="Аватар"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(effectiveIconUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center self-end"
|
||||
@@ -164,6 +233,44 @@ export const UserEditPage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editUserData.name || "Пользователь"}
|
||||
contextType="user"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteIconModalOpen}
|
||||
onDelete={() => {
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false,
|
||||
""
|
||||
);
|
||||
setIsDeleteIconModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteIconModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export const VehicleCreatePage = observer(() => {
|
||||
const [tailNumber, setTailNumber] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||
const [model, setModel] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
@@ -40,7 +41,8 @@ export const VehicleCreatePage = observer(() => {
|
||||
Number(type),
|
||||
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
|
||||
?.full_name as string,
|
||||
carrierId!
|
||||
carrierId!,
|
||||
model || undefined,
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
} catch (error) {
|
||||
@@ -103,6 +105,14 @@ export const VehicleCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Модель ТС"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="Произвольное название модели"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
InputLabel,
|
||||
Button,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
@@ -54,10 +56,13 @@ export const VehicleEditPage = observer(() => {
|
||||
await getCarriers(language);
|
||||
|
||||
setEditVehicleData({
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
|
||||
type: vehicle[Number(id)]?.vehicle.type,
|
||||
carrier: vehicle[Number(id)]?.vehicle.carrier,
|
||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
|
||||
type: vehicle[Number(id)]?.vehicle.type ?? 0,
|
||||
carrier: vehicle[Number(id)]?.vehicle.carrier ?? "",
|
||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id ?? 0,
|
||||
model: vehicle[Number(id)]?.vehicle.model ?? "",
|
||||
snapshot_update_blocked:
|
||||
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
@@ -159,6 +164,35 @@ export const VehicleEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Модель ТС"
|
||||
value={editVehicleData.model}
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
model: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Произвольное название модели"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={editVehicleData.snapshot_update_blocked}
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
snapshot_update_blocked: e.target.checked,
|
||||
})
|
||||
}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Блокировка обновления ПО"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
|
||||
@@ -36,7 +36,7 @@ export const NAVIGATION_ITEMS: {
|
||||
primary: [
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
label: "Экспорт",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
@@ -124,6 +124,16 @@ export const NAVIGATION_ITEMS: {
|
||||
};
|
||||
|
||||
export const VEHICLE_TYPES = [
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Автобус", value: 3 },
|
||||
{ label: "Троллейбус", value: 2 },
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Электробус", value: 4 },
|
||||
{ label: "Электричка", value: 5 },
|
||||
{ label: "Вагон метро", value: 6 },
|
||||
{ label: "Вагон ЖД", value: 7 },
|
||||
];
|
||||
|
||||
export const VEHICLE_MODELS = [
|
||||
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
|
||||
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
|
||||
] as const;
|
||||
|
||||
@@ -33,3 +33,12 @@ export const generateDefaultMediaName = (
|
||||
|
||||
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||
};
|
||||
|
||||
/** Медиа-id считается пустым, если строка пустая или состоит только из нулей (с дефисами или без). */
|
||||
export const isMediaIdEmpty = (
|
||||
id: string | null | undefined
|
||||
): boolean => {
|
||||
if (id == null || id === "") return true;
|
||||
const digits = id.replace(/-/g, "");
|
||||
return digits === "" || /^0+$/.test(digits);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,9 @@ interface UploadMediaDialogProps {
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
| "station"
|
||||
| "route"
|
||||
| "user";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance, languageInstance, languageStore } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
languageInstance,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
} from "@shared";
|
||||
|
||||
export type Route = {
|
||||
route_name: string;
|
||||
@@ -9,6 +14,7 @@ export type Route = {
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
icon: string;
|
||||
path: number[][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
@@ -137,6 +143,7 @@ class RouteStore {
|
||||
center_longitude: "",
|
||||
governor_appeal: 0,
|
||||
id: 0,
|
||||
icon: "",
|
||||
path: [] as number[][],
|
||||
rotate: 0,
|
||||
route_direction: false,
|
||||
@@ -152,9 +159,15 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
if (!this.editRouteData.video_preview) {
|
||||
if (
|
||||
!this.editRouteData.video_preview ||
|
||||
isMediaIdEmpty(this.editRouteData.video_preview)
|
||||
) {
|
||||
delete this.editRouteData.video_preview;
|
||||
}
|
||||
if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) {
|
||||
delete (this.editRouteData as any).icon;
|
||||
}
|
||||
const dataToSend: any = {
|
||||
...this.editRouteData,
|
||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||
|
||||
@@ -7,6 +7,7 @@ export type User = {
|
||||
is_admin: boolean;
|
||||
name: string;
|
||||
password?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
class UserStore {
|
||||
@@ -57,15 +58,23 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
};
|
||||
|
||||
setCreateUserData = (
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
is_admin: boolean
|
||||
is_admin: boolean,
|
||||
icon?: string
|
||||
) => {
|
||||
this.createUserData = { name, email, password, is_admin };
|
||||
this.createUserData = {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
icon: icon ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
createUser = async () => {
|
||||
@@ -73,7 +82,9 @@ class UserStore {
|
||||
if (this.users.data.length > 0) {
|
||||
id = this.users.data[this.users.data.length - 1].id + 1;
|
||||
}
|
||||
const response = await authInstance.post("/user", this.createUserData);
|
||||
const payload = { ...this.createUserData };
|
||||
if (!payload.icon) delete payload.icon;
|
||||
const response = await authInstance.post("/user", payload);
|
||||
|
||||
runInAction(() => {
|
||||
this.users.data.push({
|
||||
@@ -88,19 +99,30 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
};
|
||||
|
||||
setEditUserData = (
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
is_admin: boolean
|
||||
is_admin: boolean,
|
||||
icon?: string
|
||||
) => {
|
||||
this.editUserData = { name, email, password, is_admin };
|
||||
this.editUserData = {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
icon: icon ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
editUser = async (id: number) => {
|
||||
const response = await authInstance.patch(`/user/${id}`, this.editUserData);
|
||||
const payload = { ...this.editUserData };
|
||||
if (!payload.icon) delete payload.icon;
|
||||
if (!payload.password?.trim()) delete payload.password;
|
||||
const response = await authInstance.patch(`/user/${id}`, payload);
|
||||
|
||||
runInAction(() => {
|
||||
this.users.data = this.users.data.map((user) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { authInstance, languageInstance } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Vehicle = {
|
||||
@@ -9,6 +9,12 @@ export type Vehicle = {
|
||||
carrier_id: number;
|
||||
carrier: string;
|
||||
uuid?: string;
|
||||
model?: string;
|
||||
current_snapshot_uuid?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
demo_mode_enabled?: boolean;
|
||||
maintenance_mode_on?: boolean;
|
||||
city_id?: number;
|
||||
};
|
||||
device_status?: {
|
||||
device_uuid: string;
|
||||
@@ -34,11 +40,75 @@ class VehicleStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
private normalizeVehicleItem = (item: any): Vehicle => {
|
||||
if (item && typeof item === "object" && "vehicle" in item) {
|
||||
return {
|
||||
vehicle: item.vehicle ?? {},
|
||||
device_status: item.device_status,
|
||||
} as Vehicle;
|
||||
}
|
||||
|
||||
return {
|
||||
vehicle: item ?? {},
|
||||
} as Vehicle;
|
||||
};
|
||||
|
||||
private mergeVehicleInCaches = (updatedVehicle: any) => {
|
||||
if (!updatedVehicle) return;
|
||||
|
||||
const updatedId = updatedVehicle.id;
|
||||
const updatedUuid = updatedVehicle.uuid;
|
||||
|
||||
const mergeItem = (item: Vehicle): Vehicle => ({
|
||||
...item,
|
||||
vehicle: {
|
||||
...item.vehicle,
|
||||
...updatedVehicle,
|
||||
},
|
||||
});
|
||||
|
||||
this.vehicles.data = this.vehicles.data.map((item) => {
|
||||
const sameId = updatedId != null && item.vehicle.id === updatedId;
|
||||
const sameUuid =
|
||||
updatedUuid != null &&
|
||||
item.vehicle.uuid != null &&
|
||||
item.vehicle.uuid === updatedUuid;
|
||||
|
||||
if (!sameId && !sameUuid) return item;
|
||||
|
||||
return mergeItem(item);
|
||||
});
|
||||
|
||||
if (updatedId != null) {
|
||||
const existing = this.vehicle[updatedId];
|
||||
this.vehicle[updatedId] = existing
|
||||
? mergeItem(existing)
|
||||
: ({ vehicle: updatedVehicle } as Vehicle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedUuid != null) {
|
||||
const entry = Object.entries(this.vehicle).find(
|
||||
([, item]) => item.vehicle.uuid === updatedUuid
|
||||
);
|
||||
|
||||
if (entry) {
|
||||
const [key, item] = entry;
|
||||
this.vehicle[key] = mergeItem(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getVehicles = async () => {
|
||||
const response = await languageInstance("ru").get(`/vehicle`);
|
||||
const vehiclesList = Array.isArray(response.data)
|
||||
? response.data
|
||||
: Array.isArray(response.data?.vehicles)
|
||||
? response.data.vehicles
|
||||
: [];
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicles.data = response.data;
|
||||
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
|
||||
this.vehicles.loaded = true;
|
||||
});
|
||||
};
|
||||
@@ -55,9 +125,10 @@ class VehicleStore {
|
||||
|
||||
getVehicle = async (id: number) => {
|
||||
const response = await languageInstance("ru").get(`/vehicle/${id}`);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicle[id] = response.data;
|
||||
this.vehicle[id] = normalizedVehicle;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -65,26 +136,25 @@ class VehicleStore {
|
||||
tailNumber: string,
|
||||
type: number,
|
||||
carrier: string,
|
||||
carrierId: number
|
||||
carrierId: number,
|
||||
model?: string
|
||||
) => {
|
||||
const response = await languageInstance("ru").post("/vehicle", {
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: tailNumber,
|
||||
type,
|
||||
carrier,
|
||||
carrier_id: carrierId,
|
||||
});
|
||||
};
|
||||
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
||||
if (model != null && model !== "") payload.model = model;
|
||||
const response = await languageInstance("ru").post("/vehicle", payload);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicles.data.push({
|
||||
vehicle: {
|
||||
id: response.data.id,
|
||||
tail_number: response.data.tail_number,
|
||||
type: response.data.type,
|
||||
carrier_id: response.data.carrier_id,
|
||||
carrier: response.data.carrier,
|
||||
uuid: response.data.uuid,
|
||||
},
|
||||
});
|
||||
this.vehicles.data.push(normalizedVehicle);
|
||||
if (normalizedVehicle.vehicle?.id != null) {
|
||||
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,11 +163,15 @@ class VehicleStore {
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model: string;
|
||||
snapshot_update_blocked: boolean;
|
||||
} = {
|
||||
tail_number: "",
|
||||
type: 0,
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
model: "",
|
||||
snapshot_update_blocked: false,
|
||||
};
|
||||
|
||||
setEditVehicleData = (data: {
|
||||
@@ -105,6 +179,8 @@ class VehicleStore {
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
}) => {
|
||||
this.editVehicleData = {
|
||||
...this.editVehicleData,
|
||||
@@ -119,30 +195,68 @@ class VehicleStore {
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
}
|
||||
) => {
|
||||
const response = await languageInstance("ru").patch(`/vehicle/${id}`, {
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: data.tail_number,
|
||||
type: data.type,
|
||||
carrier: data.carrier,
|
||||
carrier_id: data.carrier_id,
|
||||
});
|
||||
};
|
||||
if (data.model != null && data.model !== "") payload.model = data.model;
|
||||
if (data.snapshot_update_blocked != null)
|
||||
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
||||
const response = await languageInstance("ru").patch(
|
||||
`/vehicle/${id}`,
|
||||
payload
|
||||
);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
const updatedVehiclePayload = {
|
||||
...normalizedVehicle.vehicle,
|
||||
model: normalizedVehicle.vehicle.model ?? data.model,
|
||||
snapshot_update_blocked:
|
||||
normalizedVehicle.vehicle.snapshot_update_blocked ??
|
||||
data.snapshot_update_blocked,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicle[id] = {
|
||||
vehicle: {
|
||||
...this.vehicle[id].vehicle,
|
||||
...response.data,
|
||||
},
|
||||
this.mergeVehicleInCaches({
|
||||
...updatedVehiclePayload,
|
||||
id,
|
||||
});
|
||||
});
|
||||
};
|
||||
this.vehicles.data = this.vehicles.data.map((vehicle) =>
|
||||
vehicle.vehicle.id === id
|
||||
? {
|
||||
...vehicle,
|
||||
...response.data,
|
||||
}
|
||||
: vehicle
|
||||
);
|
||||
|
||||
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||
const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
|
||||
enabled,
|
||||
});
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.mergeVehicleInCaches({
|
||||
...normalizedVehicle.vehicle,
|
||||
uuid,
|
||||
maintenance_mode_on:
|
||||
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setDemoMode = async (uuid: string, enabled: boolean) => {
|
||||
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
|
||||
enabled,
|
||||
});
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.mergeVehicleInCaches({
|
||||
...normalizedVehicle.vehicle,
|
||||
uuid,
|
||||
demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Modal as MuiModal, Typography, Box } from "@mui/material";
|
||||
import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const style = {
|
||||
@@ -19,7 +20,7 @@ const style = {
|
||||
borderRadius: 2,
|
||||
};
|
||||
|
||||
export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
|
||||
return (
|
||||
<MuiModal
|
||||
open={open}
|
||||
@@ -27,7 +28,7 @@ export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Box sx={{ ...style, ...sx }}>
|
||||
{title && (
|
||||
<Typography
|
||||
id="modal-modal-title"
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
338
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
338
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { API_URL, authInstance, Modal } from "@shared";
|
||||
import { Button, CircularProgress, TextField } from "@mui/material";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
interface DeviceLogChunk {
|
||||
date?: string;
|
||||
lines?: string[];
|
||||
}
|
||||
|
||||
interface DeviceLogsModalProps {
|
||||
open: boolean;
|
||||
deviceUuid: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const shiftYYYYMMDD = (value: string, days: number) => {
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return toYYYYMMDD(d);
|
||||
};
|
||||
|
||||
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
|
||||
|
||||
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
|
||||
info: {
|
||||
badge: "bg-blue-100 text-blue-700",
|
||||
text: "text-[#000000BF]",
|
||||
},
|
||||
debug: {
|
||||
badge: "bg-gray-100 text-gray-600",
|
||||
text: "text-gray-600",
|
||||
},
|
||||
warn: {
|
||||
badge: "bg-amber-100 text-amber-700",
|
||||
text: "text-amber-800",
|
||||
},
|
||||
error: {
|
||||
badge: "bg-red-100 text-red-700",
|
||||
text: "text-red-700",
|
||||
},
|
||||
fatal: {
|
||||
badge: "bg-red-200 text-red-900",
|
||||
text: "text-red-900 font-semibold",
|
||||
},
|
||||
unknown: {
|
||||
badge: "bg-gray-100 text-gray-500",
|
||||
text: "text-[#000000BF]",
|
||||
},
|
||||
};
|
||||
|
||||
const formatTs = (raw: string): string => {
|
||||
try {
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return raw;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonLogLine = (line: string) => {
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (obj && typeof obj === "object") {
|
||||
const level: LogLevel =
|
||||
obj.level && obj.level in LOG_LEVEL_STYLES
|
||||
? (obj.level as LogLevel)
|
||||
: "unknown";
|
||||
const ts: string = obj.ts ? formatTs(obj.ts) : "";
|
||||
const msg: string = obj.msg ?? "";
|
||||
|
||||
const extra: Record<string, unknown> = { ...obj };
|
||||
delete extra.level;
|
||||
delete extra.ts;
|
||||
delete extra.msg;
|
||||
delete extra.caller;
|
||||
const extraStr = Object.keys(extra).length
|
||||
? " " +
|
||||
Object.entries(extra)
|
||||
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join(" ")
|
||||
: "";
|
||||
|
||||
return { ts, level, msg, extraStr };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseLogLine = (line: string, index: number) => {
|
||||
const json = parseJsonLogLine(line);
|
||||
if (json) {
|
||||
return {
|
||||
id: index,
|
||||
time: json.ts,
|
||||
text: json.msg + json.extraStr,
|
||||
level: json.level,
|
||||
sortKey: json.ts,
|
||||
};
|
||||
}
|
||||
|
||||
const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/);
|
||||
if (bracketMatch) {
|
||||
const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim();
|
||||
return {
|
||||
id: index,
|
||||
time: formatTs(rawTime),
|
||||
text: bracketMatch[2].trim() || line,
|
||||
level: "unknown" as LogLevel,
|
||||
sortKey: rawTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: index,
|
||||
time: "",
|
||||
text: line,
|
||||
level: "unknown" as LogLevel,
|
||||
sortKey: "",
|
||||
};
|
||||
};
|
||||
|
||||
export const DeviceLogsModal = ({
|
||||
open,
|
||||
deviceUuid,
|
||||
onClose,
|
||||
}: DeviceLogsModalProps) => {
|
||||
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
|
||||
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
|
||||
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
|
||||
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
|
||||
|
||||
const handleDateFromChange = (value: string) => {
|
||||
setDateFrom(value);
|
||||
if (!dateTo || dateTo <= value) {
|
||||
setDateTo(shiftYYYYMMDD(value, 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateToChange = (value: string) => {
|
||||
if (value <= dateFrom) {
|
||||
toast.info("Дата 'До' должна быть позже даты 'От'");
|
||||
setDateTo(shiftYYYYMMDD(dateFrom, 1));
|
||||
return;
|
||||
}
|
||||
setDateTo(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !deviceUuid) return;
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await authInstance.get<DeviceLogChunk[]>(
|
||||
`${API_URL}/devices/${deviceUuid}/logs`,
|
||||
{
|
||||
params: {
|
||||
from: dateFrom,
|
||||
to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)),
|
||||
},
|
||||
}
|
||||
);
|
||||
setChunks(Array.isArray(data) ? data : []);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === "object" && "message" in err
|
||||
? String((err as { message?: string }).message)
|
||||
: "Ошибка загрузки логов";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
}, [open, deviceUuid, dateFrom, dateTo]);
|
||||
|
||||
const logs = useMemo(() => {
|
||||
const parsed = chunks.flatMap((chunk, chunkIdx) =>
|
||||
(chunk.lines ?? []).map((line, i) =>
|
||||
parseLogLine(line, chunkIdx * 10000 + i)
|
||||
)
|
||||
);
|
||||
parsed.sort((a, b) => {
|
||||
if (!a.sortKey && !b.sortKey) return 0;
|
||||
if (!a.sortKey) return 1;
|
||||
if (!b.sortKey) return -1;
|
||||
return b.sortKey.localeCompare(a.sortKey);
|
||||
});
|
||||
return parsed;
|
||||
}, [chunks]);
|
||||
|
||||
const logsText = useMemo(
|
||||
() =>
|
||||
logs
|
||||
.map((log) => {
|
||||
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
|
||||
const time = log.time ? `[${log.time}] ` : "";
|
||||
return `${time}${level}: ${log.text}`;
|
||||
})
|
||||
.join("\n"),
|
||||
[logs]
|
||||
);
|
||||
|
||||
const handleDownloadLogs = () => {
|
||||
if (!logsText) {
|
||||
toast.info("Нет логов для сохранения");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const safeDeviceUuid = (deviceUuid ?? "device").replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"_"
|
||||
);
|
||||
const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`;
|
||||
const blob = new Blob([`\uFEFF${logsText}`], {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Логи сохранены в .txt");
|
||||
} catch {
|
||||
toast.error("Не удалось сохранить логи");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
|
||||
<div className="flex flex-col gap-6 h-[85vh]">
|
||||
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
|
||||
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
||||
<div className="flex gap-4 items-center">
|
||||
<TextField
|
||||
type="date"
|
||||
label="От"
|
||||
size="small"
|
||||
value={dateFrom}
|
||||
onChange={(e) => handleDateFromChange(e.target.value)}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { max: dateFromMax },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
label="До"
|
||||
size="small"
|
||||
value={dateTo}
|
||||
onChange={(e) => handleDateToChange(e.target.value)}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { min: dateToMin },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleDownloadLogs}
|
||||
disabled={isLoading || Boolean(error) || logs.length === 0}
|
||||
>
|
||||
Скачать .txt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 w-full">
|
||||
{isLoading && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="w-full h-full flex items-center justify-center text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="w-full h-full overflow-y-auto rounded-xl">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{logs.length > 0 ? (
|
||||
logs.map((log) => {
|
||||
const style = LOG_LEVEL_STYLES[log.level];
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex gap-3 items-start px-2 py-1 rounded ${style.text}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-[11px] font-semibold uppercase ${style.badge}`}
|
||||
>
|
||||
{log.level === "unknown" ? "LOG" : log.level}
|
||||
</span>
|
||||
<span className="text-gray-400 shrink-0 whitespace-nowrap">
|
||||
{log.time || null}
|
||||
</span>
|
||||
<span className="break-all">{log.text}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 py-10">
|
||||
Логи отсутствуют.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
|
||||
import { Drawer } from "./ui/Drawer";
|
||||
import { DrawerHeader } from "./ui/DrawerHeader";
|
||||
import { NavigationList } from "@features";
|
||||
import { authStore, userStore, menuStore } from "@shared";
|
||||
import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { Typography } from "@mui/material";
|
||||
@@ -67,18 +67,18 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
const currentUser = users?.data?.find(
|
||||
(user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
|
||||
);
|
||||
const hasAvatar =
|
||||
currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className=" text-white">
|
||||
{
|
||||
users?.data?.find(
|
||||
// @ts-ignore
|
||||
(user) => user.id === authStore.payload?.user_id
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-white">{currentUser?.name}</p>
|
||||
<div
|
||||
className="text-center text-xs"
|
||||
style={{
|
||||
@@ -88,19 +88,28 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
padding: "2px 10px",
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{authStore.payload?.is_admin
|
||||
{(authStore.payload as { is_admin?: boolean })?.is_admin
|
||||
? "Администратор"
|
||||
: "Режим пользователя"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden bg-gray-600 shrink-0">
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${currentUser!.icon}/download?token=${token}`}
|
||||
alt="Аватар"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="text-white" size={20} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
|
||||
<User />
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant="permanent" open={open}>
|
||||
@@ -138,6 +147,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
|
||||
<div className="mt-auto flex justify-center items-center pb-5 text-sm text-gray-300">
|
||||
v.{__APP_VERSION__}
|
||||
</div>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
|
||||
@@ -123,7 +123,7 @@ export const MediaArea = observer(
|
||||
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
|
||||
{mediaIds.map((m) => (
|
||||
<button
|
||||
className="relative w-20 h-20"
|
||||
className="relative w-[100px] h-[80px]"
|
||||
key={m.id}
|
||||
onClick={() => handleMediaModal(m.id)}
|
||||
type="button"
|
||||
@@ -134,7 +134,7 @@ export const MediaArea = observer(
|
||||
media_type: m.media_type,
|
||||
filename: m.filename,
|
||||
}}
|
||||
height="40px"
|
||||
compact
|
||||
/>
|
||||
<button
|
||||
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
|
||||
|
||||
@@ -129,7 +129,7 @@ export const ThreeView = ({
|
||||
>
|
||||
<ambientLight />
|
||||
<directionalLight />
|
||||
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
||||
<Stage environment={null} intensity={0.6} adjustCamera={false}>
|
||||
<Model fileUrl={fileUrl} />
|
||||
</Stage>
|
||||
<OrbitControls />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cuboid } from "lucide-react";
|
||||
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ThreeView } from "./ThreeView";
|
||||
@@ -19,6 +20,7 @@ export function MediaViewer({
|
||||
width,
|
||||
fullWidth,
|
||||
fullHeight,
|
||||
compact,
|
||||
}: Readonly<{
|
||||
media?: MediaData;
|
||||
className?: string;
|
||||
@@ -26,6 +28,8 @@ export function MediaViewer({
|
||||
width?: string;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
/** В компактном режиме (миниатюры) 3D модели не рендерятся — показывается placeholder */
|
||||
compact?: boolean;
|
||||
}>) {
|
||||
const token = localStorage.getItem("token");
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
@@ -76,8 +80,9 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -88,8 +93,8 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
style={{
|
||||
width: width ? width : "100%",
|
||||
height: height ? height : "100%",
|
||||
width: compact ? "100px" : width ? width : "100%",
|
||||
height: compact ? "80px" : height ? height : "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
@@ -105,8 +110,9 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -117,7 +123,8 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
width: "100%",
|
||||
width: compact ? "100px" : "100%",
|
||||
height: compact ? "80px" : undefined,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
@@ -128,12 +135,32 @@ export function MediaViewer({
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
width={fullWidth ? "100%" : width ? width : "500px"}
|
||||
height={fullHeight ? "100%" : height ? height : "300px"}
|
||||
width={compact ? "100px" : fullWidth ? "100%" : width ? width : "500px"}
|
||||
height={compact ? "80px" : fullHeight ? "100%" : height ? height : "300px"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media?.media_type === 6 && (
|
||||
{media?.media_type === 6 &&
|
||||
(compact ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100px",
|
||||
height: "80px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "action.hover",
|
||||
borderRadius: 5,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<Cuboid size={24} />
|
||||
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
||||
3D
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<ThreeViewErrorBoundary
|
||||
onReset={handleReset}
|
||||
resetKey={`${media?.id}-${resetKey}`}
|
||||
@@ -147,7 +174,7 @@ export function MediaViewer({
|
||||
width={width ? width : "500px"}
|
||||
/>
|
||||
</ThreeViewErrorBoundary>
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
languageStore,
|
||||
Language,
|
||||
cityStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
SightLanguageInfo,
|
||||
@@ -308,7 +309,7 @@ export const CreateInformationTab = observer(
|
||||
<ImageUploadCard
|
||||
title="Водяной знак (левый верхний)"
|
||||
imageKey="watermark_lu"
|
||||
imageUrl={sight.watermark_lu}
|
||||
imageUrl={isMediaIdEmpty(sight.watermark_lu) ? null : sight.watermark_lu}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.watermark_lu ?? "");
|
||||
@@ -363,7 +364,7 @@ export const CreateInformationTab = observer(
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={sight.video_preview}
|
||||
videoId={isMediaIdEmpty(sight.video_preview) ? null : sight.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
handleChange({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Language,
|
||||
cityStore,
|
||||
editSightStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
SightLanguageInfo,
|
||||
@@ -334,7 +335,7 @@ export const InformationTab = observer(
|
||||
<ImageUploadCard
|
||||
title="Водяной знак (левый верхний)"
|
||||
imageKey="watermark_lu"
|
||||
imageUrl={sight.common.watermark_lu}
|
||||
imageUrl={isMediaIdEmpty(sight.common.watermark_lu) ? null : sight.common.watermark_lu}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.common.watermark_lu ?? "");
|
||||
@@ -396,7 +397,7 @@ export const InformationTab = observer(
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={sight.common.video_preview}
|
||||
videoId={isMediaIdEmpty(sight.common.video_preview) ? null : sight.common.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
handleChange(
|
||||
|
||||
@@ -19,7 +19,7 @@ export const SnapshotRestore = ({
|
||||
>
|
||||
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
|
||||
<p className="text-black w-110 text-center">
|
||||
Вы уверены, что хотите восстановить этот снапшот?
|
||||
Вы уверены, что хотите восстановить этот экспорт медиа?
|
||||
</p>
|
||||
<p className="text-black w-100 text-center">
|
||||
Это действие нельзя будет отменить.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,12 +2,10 @@ import { defineConfig, type UserConfigExport } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": path.resolve(__dirname, "src/shared"),
|
||||
@@ -18,9 +16,11 @@ export default defineConfig({
|
||||
"@app": path.resolve(__dirname, "src/app"),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
|
||||
build: {
|
||||
chunkSizeWarningLimit: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user