feat: Add checkbox for sightbar entity + fix build errors

This commit is contained in:
2025-06-09 18:48:25 +03:00
parent 2ca1f2cba4
commit e2e750877a
25 changed files with 302 additions and 269 deletions

View File

@ -6,7 +6,6 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
@ -15,8 +14,7 @@ import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared"; import { carrierStore, cityStore, mediaStore } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { HexColorPicker } from "react-colorful";
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -1,5 +1,5 @@
import { Paper } from "@mui/material"; import { Paper } from "@mui/material";
import { carrierStore, languageStore, mediaStore } from "@shared"; import { carrierStore, mediaStore } from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";

View File

@ -32,7 +32,7 @@ export const CityEditPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData } = cityStore; const { editCityData, editCity, getCity, setEditCityData } = cityStore;
const { getCountries } = countryStore; const { getCountries } = countryStore;
const { getMedia, getOneMedia, oneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
const handleEdit = async () => { const handleEdit = async () => {
try { try {

View File

@ -40,7 +40,7 @@ export const CreateSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await getCities(); await getCities("ru");
await getArticles(languageStore.language); await getArticles(languageStore.language);
}; };
fetchData(); fetchData();

View File

@ -13,7 +13,6 @@ import VectorLayer from "ol/layer/Vector";
import VectorSource, { VectorSourceEvent } from "ol/source/Vector"; import VectorSource, { VectorSourceEvent } from "ol/source/Vector";
import { Draw, Modify, Select } from "ol/interaction"; import { Draw, Modify, Select } from "ol/interaction";
import { DrawEvent } from "ol/interaction/Draw"; import { DrawEvent } from "ol/interaction/Draw";
import { ModifyEvent } from "ol/interaction/Modify";
import { SelectEvent } from "ol/interaction/Select"; import { SelectEvent } from "ol/interaction/Select";
import { import {
Style, Style,
@ -34,7 +33,6 @@ import {
Landmark, Landmark,
Pencil, Pencil,
Save, Save,
Plus,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@ -382,7 +380,8 @@ class MapService {
this.modifyInteraction = new Modify({ this.modifyInteraction = new Modify({
source: this.vectorSource, source: this.vectorSource,
style: (feature) => { // @ts-ignore
style: (feature: FeatureLike) => {
const originalFeature = feature.get("features")[0]; const originalFeature = feature.get("features")[0];
if ( if (
originalFeature && originalFeature &&
@ -475,7 +474,7 @@ class MapService {
); );
}); });
this.modifyInteraction.on("modifyend", (event: ModifyEvent) => { this.modifyInteraction.on("modifyend", () => {
if (this.beforeModifyState) { if (this.beforeModifyState) {
this.addStateToHistory("modify-before", this.beforeModifyState); this.addStateToHistory("modify-before", this.beforeModifyState);
this.beforeModifyState = null; this.beforeModifyState = null;
@ -976,7 +975,54 @@ class MapService {
} }
} }
// --- ИСПРАВЛЕННЫЙ МЕТОД --- // --- НОВОЕ ---
// Метод для множественного удаления объектов по их ID
public deleteMultipleFeatures(featureIds: (string | number)[]): void {
if (!featureIds || featureIds.length === 0) return;
// Вывод в консоль по требованию
console.log("Запрос на множественное удаление. ID объектов:", featureIds);
const currentState = this.getCurrentStateAsGeoJSON();
if (currentState) {
this.addStateToHistory("multiple delete", currentState);
}
const selectedFeaturesCollection = this.selectInteraction?.getFeatures();
let deletedCount = 0;
featureIds.forEach((id) => {
const feature = this.vectorSource.getFeatureById(id);
if (feature) {
// Удаление из "бэкенда"/стора для каждого объекта
const recourse = String(id).split("-")[0];
const numericId = String(id).split("-")[1];
if (recourse && numericId) {
mapStore.deleteRecourse(recourse, Number(numericId));
}
// Если удаляемый объект выбран для редактирования, убираем его из выделения
if (selectedFeaturesCollection?.getArray().includes(feature)) {
selectedFeaturesCollection.remove(feature);
}
// Удаляем объект с карты
this.vectorSource.removeFeature(feature);
deletedCount++;
}
});
if (deletedCount > 0) {
// Если основное выделение стало пустым, оповещаем React
if (selectedFeaturesCollection?.getLength() === 0) {
this.onFeatureSelect(null);
}
toast.success(`Удалено ${deletedCount} объект(ов).`);
} else {
toast.warn("Не найдено объектов для удаления.");
}
}
public getAllFeaturesAsGeoJSON(): string | null { public getAllFeaturesAsGeoJSON(): string | null {
if (!this.vectorSource || !this.map) return null; if (!this.vectorSource || !this.map) return null;
const feats = this.vectorSource.getFeatures(); const feats = this.vectorSource.getFeatures();
@ -984,11 +1030,9 @@ class MapService {
const geoJSONFmt = new GeoJSON(); const geoJSONFmt = new GeoJSON();
// Просто передаем опции трансформации в метод writeFeatures.
// Он сам всё сделает правильно, не трогая оригинальные объекты.
return geoJSONFmt.writeFeatures(feats, { return geoJSONFmt.writeFeatures(feats, {
dataProjection: "EPSG:4326", // В какую проекцию конвертировать (стандарт для GeoJSON) dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection(), // В какой проекции находятся объекты на карте featureProjection: this.map.getView().getProjection(),
}); });
} }
@ -1111,6 +1155,11 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
const [activeSection, setActiveSection] = useState<string | null>("layers"); const [activeSection, setActiveSection] = useState<string | null>("layers");
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// --- НОВОЕ ---
// Состояние для хранения ID объектов, выбранных для удаления
const [selectedForDeletion, setSelectedForDeletion] = useState<
Set<string | number>
>(new Set());
const toggleSection = (id: string) => const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id); setActiveSection(activeSection === id ? null : id);
@ -1135,6 +1184,40 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
[mapService] [mapService]
); );
// --- НОВОЕ ---
// Обработчик изменения состояния чекбокса
const handleCheckboxChange = useCallback(
(id: string | number | undefined) => {
if (id === undefined) return;
setSelectedForDeletion((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
},
[]
);
// --- НОВОЕ ---
// Обработчик для запуска множественного удаления
const handleBulkDelete = useCallback(() => {
if (!mapService || selectedForDeletion.size === 0) return;
if (
window.confirm(
`Вы уверены, что хотите удалить ${selectedForDeletion.size} объект(ов)? Это действие нельзя отменить.`
)
) {
const idsToDelete = Array.from(selectedForDeletion);
mapService.deleteMultipleFeatures(idsToDelete);
setSelectedForDeletion(new Set()); // Очищаем выбор после удаления
}
}, [mapService, selectedForDeletion]);
const handleEditFeature = useCallback( const handleEditFeature = useCallback(
(featureType: string | undefined, fullId: string | number | undefined) => { (featureType: string | undefined, fullId: string | number | undefined) => {
if (!featureType || !fullId) return; if (!featureType || !fullId) return;
@ -1191,18 +1274,35 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
const sId = s.getId(); const sId = s.getId();
const sName = (s.get("name") as string) || "Без названия"; const sName = (s.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === sId; const isSelected = selectedFeature?.getId() === sId;
// --- ИЗМЕНЕНИЕ ---
const isCheckedForDeletion =
sId !== undefined && selectedForDeletion.has(sId);
return ( return (
<div <div
key={String(sId)} key={String(sId)}
className={`flex items-start justify-between p-2 rounded-md cursor-pointer group transition-colors duration-150 ${ className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected isSelected
? "bg-orange-100 border border-orange-300 hover:bg-orange-200" ? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50" : "hover:bg-blue-50"
}`} }`}
onClick={() => handleFeatureClick(sId)}
> >
<div className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0"> {/* --- НОВОЕ: Чекбокс для множественного выбора --- */}
<div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={!!isCheckedForDeletion}
onChange={() => handleCheckboxChange(sId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${sName} для удаления`}
/>
</div>
<div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(sId)}
>
<div className="flex items-center"> <div className="flex items-center">
<MapPin <MapPin
size={16} size={16}
@ -1266,6 +1366,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
const lId = l.getId(); const lId = l.getId();
const lName = (l.get("name") as string) || "Без названия"; const lName = (l.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === lId; const isSelected = selectedFeature?.getId() === lId;
const isCheckedForDeletion =
lId !== undefined && selectedForDeletion.has(lId);
const lGeom = l.getGeometry(); const lGeom = l.getGeometry();
let lineLengthText: string | null = null; let lineLengthText: string | null = null;
if (lGeom instanceof LineString) { if (lGeom instanceof LineString) {
@ -1276,14 +1378,26 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
return ( return (
<div <div
key={String(lId)} key={String(lId)}
className={`flex items-start justify-between p-2 rounded-md cursor-pointer group transition-colors duration-150 ${ className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected isSelected
? "bg-orange-100 border border-orange-300 hover:bg-orange-200" ? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50" : "hover:bg-blue-50"
}`} }`}
onClick={() => handleFeatureClick(lId)}
> >
<div className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0"> <div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={!!isCheckedForDeletion}
onChange={() => handleCheckboxChange(lId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${lName} для удаления`}
/>
</div>
<div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(lId)}
>
<div className="flex items-center mb-0.5"> <div className="flex items-center mb-0.5">
<ArrowRightLeft <ArrowRightLeft
size={16} size={16}
@ -1352,17 +1466,31 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
const sId = s.getId(); const sId = s.getId();
const sName = (s.get("name") as string) || "Без названия"; const sName = (s.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === sId; const isSelected = selectedFeature?.getId() === sId;
const isCheckedForDeletion =
sId !== undefined && selectedForDeletion.has(sId);
return ( return (
<div <div
key={String(sId)} key={String(sId)}
className={`flex items-center justify-between p-2 rounded-md cursor-pointer group transition-colors duration-150 ${ className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected isSelected
? "bg-orange-100 border border-orange-300 hover:bg-orange-200" ? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50" : "hover:bg-blue-50"
}`} }`}
onClick={() => handleFeatureClick(sId)}
> >
<div className="flex items-center text-gray-800 text-sm flex-grow mr-2 min-w-0"> <div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={!!isCheckedForDeletion}
onChange={() => handleCheckboxChange(sId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${sName} для удаления`}
/>
</div>
<div
className="flex items-center text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(sId)}
>
<Landmark <Landmark
size={16} size={16}
className={`mr-1.5 flex-shrink-0 ${ className={`mr-1.5 flex-shrink-0 ${
@ -1427,12 +1555,13 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
} }
return ( return (
// --- ИЗМЕНЕНИЕ: Реструктуризация для футера с кнопками ---
<div className="w-72 relative md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]"> <div className="w-72 relative md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
<div className="p-4 bg-gray-700 text-white"> <div className="p-4 bg-gray-700 text-white">
<h2 className="text-lg font-semibold">Панель управления</h2> <h2 className="text-lg font-semibold">Панель управления</h2>
</div> </div>
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto max-h-[70%]"> <div className="flex-1 overflow-y-auto">
{sections.map((s) => ( {sections.map((s) => (
<div <div
key={s.id} key={s.id}
@ -1480,18 +1609,30 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
</div> </div>
</div> </div>
<button {/* --- НОВОЕ: Футер сайдбара с кнопками действий --- */}
onClick={handleSave} <div className="p-3 border-t border-gray-200 bg-gray-50/95 space-y-2">
className="m-3 w-[90%] h-[40px] flex items-center justify-center px-4 py-2 bg-blue-500 disabled:bg-blue-300 text-white rounded-md hover:bg-blue-600 transition-colors" {selectedForDeletion.size > 0 && (
disabled={isLoading} <button
> onClick={handleBulkDelete}
<Save size={16} className="mr-2" /> className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
{isLoading ? ( >
<Loader2 size={16} className="animate-spin" /> <Trash2 size={16} className="mr-2" />
) : ( Удалить выбранное ({selectedForDeletion.size})
"Сохранить изменения" </button>
)} )}
</button> <button
onClick={handleSave}
className="w-full h-[40px] flex items-center justify-center px-4 py-2 bg-blue-500 disabled:bg-blue-300 text-white rounded-md hover:bg-blue-600 transition-colors"
disabled={isLoading}
>
<Save size={16} className="mr-2" />
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
"Сохранить изменения"
)}
</button>
</div>
</div> </div>
); );
}; };
@ -1502,9 +1643,8 @@ export const MapPage: React.FC = () => {
const tooltipRef = useRef<HTMLDivElement | null>(null); const tooltipRef = useRef<HTMLDivElement | null>(null);
const [mapServiceInstance, setMapServiceInstance] = const [mapServiceInstance, setMapServiceInstance] =
useState<MapService | null>(null); useState<MapService | null>(null);
// --- ИЗМЕНЕНИЕ: Разделение состояния загрузки --- const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true); // Для рендеринга карты const [isDataLoading, setIsDataLoading] = useState<boolean>(true);
const [isDataLoading, setIsDataLoading] = useState<boolean>(true); // Для загрузки данных с API
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentMapMode, setCurrentMapMode] = useState<string>("edit"); const [currentMapMode, setCurrentMapMode] = useState<string>("edit");
const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]); const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
@ -1525,12 +1665,10 @@ export const MapPage: React.FC = () => {
useEffect(() => { useEffect(() => {
let service: MapService | null = null; let service: MapService | null = null;
if (mapRef.current && tooltipRef.current && !mapServiceInstance) { if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
// Изначально оба процесса загрузки активны
setIsMapLoading(true); setIsMapLoading(true);
setIsDataLoading(true); setIsDataLoading(true);
setError(null); setError(null);
// --- ИЗМЕНЕНИЕ: Логика загрузки данных вынесена и управляет своим состоянием ---
const loadInitialData = async (mapService: MapService) => { const loadInitialData = async (mapService: MapService) => {
console.log("Starting data load..."); console.log("Starting data load...");
try { try {
@ -1548,7 +1686,6 @@ export const MapPage: React.FC = () => {
console.error("Failed to load initial map data:", e); console.error("Failed to load initial map data:", e);
setError("Не удалось загрузить данные для карты."); setError("Не удалось загрузить данные для карты.");
} finally { } finally {
// Завершаем состояние загрузки данных независимо от результата
setIsDataLoading(false); setIsDataLoading(false);
} }
}; };
@ -1556,7 +1693,7 @@ export const MapPage: React.FC = () => {
try { try {
service = new MapService( service = new MapService(
{ ...mapConfig, target: mapRef.current }, { ...mapConfig, target: mapRef.current },
setIsMapLoading, // MapService теперь управляет только состоянием загрузки карты setIsMapLoading,
setError, setError,
setCurrentMapMode, setCurrentMapMode,
handleFeaturesChange, handleFeaturesChange,
@ -1565,7 +1702,6 @@ export const MapPage: React.FC = () => {
); );
setMapServiceInstance(service); setMapServiceInstance(service);
// Запускаем загрузку данных
loadInitialData(service); loadInitialData(service);
} catch (e: any) { } catch (e: any) {
console.error("MapPage useEffect error:", e); console.error("MapPage useEffect error:", e);
@ -1574,7 +1710,6 @@ export const MapPage: React.FC = () => {
e.message || "Неизвестная ошибка" e.message || "Неизвестная ошибка"
}. Пожалуйста, проверьте консоль.` }. Пожалуйста, проверьте консоль.`
); );
// В случае критической ошибки инициализации, завершаем все загрузки
setIsMapLoading(false); setIsMapLoading(false);
setIsDataLoading(false); setIsDataLoading(false);
} }
@ -1607,7 +1742,6 @@ export const MapPage: React.FC = () => {
pointerEvents: "none", pointerEvents: "none",
}} }}
></div> ></div>
{/* --- ИЗМЕНЕНИЕ: Обновленный лоадер --- */}
{showLoader && ( {showLoader && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-50 z-[1001]"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-50 z-[1001]">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-3"></div> <div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-3"></div>
@ -1629,7 +1763,6 @@ export const MapPage: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{/* --- ИЗМЕНЕНИЕ: Условие для отображения контента --- */}
{showContent && ( {showContent && (
<MapControls <MapControls
mapService={mapServiceInstance} mapService={mapServiceInstance}

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react"; import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
export const MediaListPage = observer(() => { export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore; const { media, getMedia, deleteMedia } = mediaStore;

View File

@ -80,9 +80,6 @@ export const RouteCreatePage = observer(() => {
path, path,
}; };
// Отправка на сервер (пример, если есть routeStore.createRoute)
let createdRoute: Route | null = null;
await routeStore.createRoute(newRoute); await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(-1); navigate(-1);

View File

@ -2,9 +2,9 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, routeStore } from "@shared"; import { languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Map, Pencil, Trash2 } from "lucide-react"; import { Map, Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
export const RouteListPage = observer(() => { export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore; const { routes, getRoutes, deleteRoute } = routeStore;

View File

@ -171,20 +171,14 @@ export const MapDataProvider = observer(
async function saveStationChanges() { async function saveStationChanges() {
for (const station of stationChanges) { for (const station of stationChanges) {
const response = await authInstance.patch( await authInstance.patch(`/route/${routeId}/station`, station);
`/route/${routeId}/station`,
station
);
} }
} }
async function saveSightChanges() { async function saveSightChanges() {
console.log("sightChanges", sightChanges); console.log("sightChanges", sightChanges);
for (const sight of sightChanges) { for (const sight of sightChanges) {
const response = await authInstance.patch( await authInstance.patch(`/route/${routeId}/sight`, sight);
`/route/${routeId}/sight`,
sight
);
} }
} }

View File

@ -2,118 +2,106 @@ import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { SightData } from "./types"; import { SightData } from "./types";
import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js"; import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js";
import { COLORS } from "../../contexts/color-mode/theme";
import { SIGHT_SIZE, UP_SCALE } from "./Constants"; import { SIGHT_SIZE, UP_SCALE } from "./Constants";
import { coordinatesToLocal, localToCoordinates } from "./utils"; import { coordinatesToLocal, localToCoordinates } from "./utils";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
interface SightProps { interface SightProps {
sight: SightData; sight: SightData;
id: number; id: number;
} }
export function Sight({ export function Sight({ sight, id }: Readonly<SightProps>) {
sight, id const { rotation, scale } = useTransform();
}: Readonly<SightProps>) { const { setSightCoordinates } = useMapData();
const { rotation, scale } = useTransform();
const { setSightCoordinates } = useMapData();
const [position, setPosition] = useState(coordinatesToLocal(sight.latitude, sight.longitude)); const [position, setPosition] = useState(
const [isDragging, setIsDragging] = useState(false); coordinatesToLocal(sight.latitude, sight.longitude)
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); );
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsDragging(true);
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y y: position.y,
}); });
setStartMousePosition({ setStartMousePosition({
x: e.globalX, x: e.globalX,
y: e.globalY y: e.globalY,
}); });
e.stopPropagation(); e.stopPropagation();
}; };
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return; if (!isDragging) return;
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE; const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE; const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
const cos = Math.cos(rotation); const cos = Math.cos(rotation);
const sin = Math.sin(rotation); const sin = Math.sin(rotation);
const newPosition = { const newPosition = {
x: startPosition.x + dx * cos + dy * sin, x: startPosition.x + dx * cos + dy * sin,
y: startPosition.y - dx * sin + dy * cos y: startPosition.y - dx * sin + dy * cos,
};
setPosition(newPosition);
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
e.stopPropagation();
}; };
setPosition(newPosition);
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false); setIsDragging(false);
e.stopPropagation(); e.stopPropagation();
}; };
const [texture, setTexture] = useState(Texture.EMPTY); const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => { useEffect(() => {
if (texture === Texture.EMPTY) { if (texture === Texture.EMPTY) {
Assets Assets.load("/SightIcon.png").then((result) => {
.load('/SightIcon.png') setTexture(result);
.then((result) => { });
setTexture(result) }
}); }, [texture]);
}
}, [texture]);
function draw(g: Graphics) { function draw(g: Graphics) {
g.clear(); g.clear();
g.circle(0, 0, 20); g.circle(0, 0, 20);
g.fill({color: COLORS.primary}); // Fill circle with primary color g.fill({ color: "#000" }); // Fill circle with primary color
} }
if(!sight) { if (!sight) {
console.error("sight is null"); console.error("sight is null");
return null; return null;
} }
const coordinates = coordinatesToLocal(sight.latitude, sight.longitude); return (
<pixiContainer
return ( rotation={-rotation}
<pixiContainer rotation={-rotation} eventMode="static"
eventMode='static' interactive
interactive onPointerDown={handlePointerDown}
onPointerDown={handlePointerDown} onGlobalPointerMove={handlePointerMove}
onGlobalPointerMove={handlePointerMove} onPointerUp={handlePointerUp}
onPointerUp={handlePointerUp} onPointerUpOutside={handlePointerUp}
onPointerUpOutside={handlePointerUp} x={position.x * UP_SCALE - SIGHT_SIZE / 2} // Offset by half width to center
x={position.x * UP_SCALE - SIGHT_SIZE/2} // Offset by half width to center y={position.y * UP_SCALE - SIGHT_SIZE / 2} // Offset by half height to center
y={position.y * UP_SCALE - SIGHT_SIZE/2} // Offset by half height to center >
> <pixiSprite texture={texture} width={SIGHT_SIZE} height={SIGHT_SIZE} />
<pixiSprite <pixiGraphics draw={draw} x={SIGHT_SIZE} y={0} />
texture={texture} <pixiText
width={SIGHT_SIZE} text={`${id + 1}`}
height={SIGHT_SIZE} x={SIGHT_SIZE + 1}
/> y={0}
<pixiGraphics anchor={0.5}
draw={draw} style={{
x={SIGHT_SIZE} fontSize: 24,
y={0} fontWeight: "bold",
/> fill: "#ffffff",
<pixiText }}
text={`${id+1}`} />
x={SIGHT_SIZE+1} </pixiContainer>
y={0} );
anchor={0.5}
style={{
fontSize: 24,
fontWeight: 'bold',
fill: "#ffffff",
}}
/>
</pixiContainer>
);
} }

View File

@ -7,12 +7,11 @@ import {
UP_SCALE, UP_SCALE,
} from "./Constants"; } from "./Constants";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { StationData } from "./types"; import { StationData } from "./types";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { languageStore } from "@shared";
interface StationProps { interface StationProps {
station: StationData; station: StationData;
@ -47,7 +46,6 @@ export const Station = observer(
export const StationLabel = observer( export const StationLabel = observer(
({ station, ruLabel }: Readonly<StationProps>) => { ({ station, ruLabel }: Readonly<StationProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform(); const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData(); const { setStationOffset } = useMapData();

View File

@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Application, ApplicationRef, extend } from "@pixi/react"; import { Application, extend } from "@pixi/react";
import { import {
Container, Container,
Graphics, Graphics,
@ -13,7 +13,7 @@ import { Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext"; import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas"; import { InfiniteCanvas } from "./InfiniteCanvas";
import { Sight } from "./Sight";
import { UP_SCALE } from "./Constants"; import { UP_SCALE } from "./Constants";
import { Station } from "./Station"; import { Station } from "./Station";
import { TravelPath } from "./TravelPath"; import { TravelPath } from "./TravelPath";

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, snapshotStore } from "@shared"; import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Eye, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";

View File

@ -6,17 +6,15 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { SelectMediaDialog } from "@shared";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -17,7 +17,7 @@ export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData, user } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
const handleEdit = async () => { const handleEdit = async () => {
try { try {

View File

@ -31,7 +31,7 @@ export const VehicleCreatePage = observer(() => {
setIsLoading(true); setIsLoading(true);
await vehicleStore.createVehicle( await vehicleStore.createVehicle(
Number(tailNumber), Number(tailNumber),
type, Number(type),
carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!, carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!,
carrierId! carrierId!
); );

View File

@ -108,7 +108,7 @@ export const VehicleEditPage = observer(() => {
}) })
} }
> >
{carrierStore.carriers.map((carrier) => ( {carrierStore.carriers.data.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}> <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name} {carrier.full_name}
</MenuItem> </MenuItem>

View File

@ -8,11 +8,8 @@ import {
Earth, Earth,
Landmark, Landmark,
BusFront, BusFront,
Bus,
GitBranch, GitBranch,
Car, Car,
Train,
Ship,
Table, Table,
Split, Split,
Newspaper, Newspaper,

View File

@ -1,4 +1,4 @@
import { authInstance, languageStore } from "@shared"; import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Carrier = { export type Carrier = {

View File

@ -1,73 +0,0 @@
import { authInstance, languageInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
type City = {
id: number;
name: string;
country_code: string;
country: string;
arms?: string;
};
class CityStore {
cities: City[] = [];
ruCities: City[] = [];
city: City | null = null;
constructor() {
makeAutoObservable(this);
}
getCities = async () => {
const response = await authInstance.get("/city");
runInAction(() => {
this.cities = response.data;
});
};
getRuCities = async () => {
const response = await languageInstance("ru").get("/city");
runInAction(() => {
this.ruCities = response.data;
});
};
deleteCity = async (id: number) => {
await authInstance.delete(`/city/${id}`);
runInAction(() => {
this.cities = this.cities.filter((city) => city.id !== id);
});
};
getCity = async (id: string) => {
const response = await authInstance.get(`/city/${id}`);
runInAction(() => {
this.city = response.data;
});
return response.data;
};
createCity = async (
name: string,
country: string,
countryCode: string,
mediaId: string
) => {
const response = await authInstance.post("/city", {
name: name,
country: country,
country_code: countryCode,
arms: mediaId,
});
runInAction(() => {
this.cities.push(response.data);
});
};
}
// export const cityStore = new CityStore();

View File

@ -256,14 +256,17 @@ class StationsStore {
// Update the cached preview data and station lists after successful patch // Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
this.stationPreview[id][language] = { this.stationPreview[id][language] = {
...this.stationPreview[id][language], // Preserve common fields that might not be in the language-specific patch response loaded: true,
id: response.data.id, data: {
name: response.data.name, ...this.stationPreview[id][language].data,
system_name: response.data.system_name, id: response.data.id,
description: response.data.description, name: response.data.name,
address: response.data.address, system_name: response.data.system_name,
...commonDataPayload, description: response.data.description,
} as Station; // Cast to Station to satisfy type address: response.data.address,
...commonDataPayload,
} as Station,
};
} }
if (this.stationLists[language].data) { if (this.stationLists[language].data) {
this.stationLists[language].data = this.stationLists[ this.stationLists[language].data = this.stationLists[
@ -327,8 +330,8 @@ class StationsStore {
}; };
} }
this.stationPreview[id][language] = { this.stationPreview[id][language] = {
data: response.data,
loaded: true, loaded: true,
data: response.data as Station,
}; };
}); });
}; };

View File

@ -1,4 +1,4 @@
import { authInstance, languageStore, languageInstance } from "@shared"; import { languageInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Vehicle = { export type Vehicle = {

View File

@ -121,7 +121,7 @@ export const DevicesTable = observer(() => {
// Transform the raw devices data into rows suitable for the table // Transform the raw devices data into rows suitable for the table
// This will also filter out devices without a UUID, as those cannot be acted upon. // This will also filter out devices without a UUID, as those cannot be acted upon.
const currentTableRows = transformDevicesToRows( const currentTableRows = transformDevicesToRows(
vehicles as Vehicle[] vehicles.data as Vehicle[]
// devices as ConnectedDevice[] // devices as ConnectedDevice[]
); );

View File

@ -35,7 +35,7 @@ export const SightsTable = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await getSights(); await getSights();
await getCities(); await getCities(language);
}; };
fetchData(); fetchData();
}, [language, getSights, getCities]); }, [language, getSights, getCities]);
@ -67,7 +67,7 @@ export const SightsTable = observer(() => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{rows(sights, cities)?.map((row) => ( {rows(sights, cities[language])?.map((row) => (
<TableRow <TableRow
key={row?.id} key={row?.id}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}

File diff suppressed because one or more lines are too long