diff --git a/package.json b/package.json index ae27d52..254525b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "lucide-react": "^0.511.0", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", + "ol": "^10.5.0", "path": "^0.12.7", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 8afce23..a37d3dc 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -5,6 +5,11 @@ import { LoginPage, MainPage, SightPage, + MapPage, + MediaListPage, + PreviewMediaPage, + EditMediaPage, + // CreateMediaPage, } from "@pages"; import { authStore, createSightStore, editSightStore } from "@shared"; import { Layout } from "@widgets"; @@ -71,6 +76,11 @@ export const Router = () => { } /> } /> } /> + } /> + } /> + } /> + } /> + {/* } /> */} ); diff --git a/src/pages/CreateMediaPage/index.tsx b/src/pages/CreateMediaPage/index.tsx new file mode 100644 index 0000000..31d3590 --- /dev/null +++ b/src/pages/CreateMediaPage/index.tsx @@ -0,0 +1,126 @@ +// import { Button, Paper, Typography, Box, Alert, Snackbar } from "@mui/material"; +// import { useNavigate } from "react-router-dom"; +// import { ArrowLeft, Upload } from "lucide-react"; +// import { observer } from "mobx-react-lite"; +// import { useState, DragEvent, useRef, useEffect } from "react"; +// import { editSightStore, UploadMediaDialog } from "@shared"; + +// export const CreateMediaPage = observer(() => { +// const navigate = useNavigate(); +// const [isDragging, setIsDragging] = useState(false); + +// const [error, setError] = useState(null); +// const [success, setSuccess] = useState(false); +// const fileInputRef = useRef(null); +// const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + +// const handleDrop = (e: DragEvent) => { +// e.preventDefault(); +// e.stopPropagation(); +// setIsDragging(false); + +// const files = Array.from(e.dataTransfer.files); +// if (files.length > 0) { +// editSightStore.fileToUpload = files[0]; +// setUploadDialogOpen(true); +// } +// }; + +// const handleDragOver = (e: DragEvent) => { +// e.preventDefault(); +// setIsDragging(true); +// }; + +// const handleDragLeave = () => { +// setIsDragging(false); +// }; + +// const handleFileSelect = (e: React.ChangeEvent) => { +// const files = e.target.files; +// if (files && files.length > 0) { +// editSightStore.fileToUpload = files[0]; +// setUploadDialogOpen(true); +// } +// }; + +// const handleUploadSuccess = () => { +// setSuccess(true); +// setUploadDialogOpen(false); +// }; + +// return ( +//
+//
+// +// Загрузка медиафайла +//
+ +// +// + +// +// +// +// Перетащите файл сюда или +// +// +// +// Поддерживаемые форматы: JPG, PNG, GIF, MP4, WebM, GLB, GLTF +// +// +// + +// setUploadDialogOpen(false)} +// afterUpload={handleUploadSuccess} +// /> + +// setSuccess(false)} +// > +// setSuccess(false)}> +// Медиафайл успешно загружен +// +// + +// setError(null)} +// > +// setError(null)}> +// {error} +// +// +//
+// ); +// }); diff --git a/src/pages/EditMediaPage/index.tsx b/src/pages/EditMediaPage/index.tsx new file mode 100644 index 0000000..1d142a4 --- /dev/null +++ b/src/pages/EditMediaPage/index.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState, useRef } from "react"; +import { observer } from "mobx-react-lite"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + TextField, + Typography, + Alert, + Snackbar, +} from "@mui/material"; +import { Save, ArrowLeft } from "lucide-react"; +import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared"; +import { MediaViewer } from "@widgets"; + +export const EditMediaPage = observer(() => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const fileInputRef = useRef(null); + const [newFile, setNewFile] = useState(null); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog + + const media = id ? mediaStore.media.find((m) => m.id === id) : null; + const [mediaName, setMediaName] = useState(media?.media_name ?? ""); + const [mediaFilename, setMediaFilename] = useState(media?.filename ?? ""); + const [mediaType, setMediaType] = useState(media?.media_type ?? 1); + + useEffect(() => { + if (id) { + mediaStore.getOneMedia(id); + } + console.log(newFile); + console.log(uploadDialogOpen); + }, [id]); + + useEffect(() => { + if (media) { + setMediaName(media.media_name); + setMediaFilename(media.filename); + setMediaType(media.media_type); + } + }, [media]); + + // const handleDrop = (e: DragEvent) => { + // e.preventDefault(); + // e.stopPropagation(); + // setIsDragging(false); + + // const files = Array.from(e.dataTransfer.files); + // if (files.length > 0) { + // setNewFile(files[0]); + // setMediaFilename(files[0].name); + // setUploadDialogOpen(true); // Open dialog on file drop + // } + // }; + + // const handleDragOver = (e: DragEvent) => { + // e.preventDefault(); + // setIsDragging(true); + // }; + + // const handleDragLeave = () => { + // setIsDragging(false); + // }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + setNewFile(files[0]); + setMediaFilename(files[0].name); + setUploadDialogOpen(true); // Open dialog on file selection + } + }; + + const handleSave = async () => { + if (!id) return; + + setIsLoading(true); + setError(null); + + try { + await authInstance.patch(`/media/${id}`, { + media_name: mediaName, + filename: mediaFilename, + type: mediaType, + }); + + // If a new file was selected, the actual file upload will happen + // via the UploadMediaDialog. We just need to make sure the metadata + // is updated correctly before or after. + // Since the dialog handles the actual upload, we don't call updateMediaFile here. + + setSuccess(true); + handleUploadSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save media"); + } finally { + setIsLoading(false); + } + }; + + const handleUploadSuccess = () => { + // After successful upload in the dialog, refresh media data if needed + if (id) { + mediaStore.getOneMedia(id); + } + setNewFile(null); // Clear the new file state after successful upload + setUploadDialogOpen(false); + setSuccess(true); + }; + + if (!media && id) { + // Only show loading if an ID is present and media is not yet loaded + return ( + + + + ); + } + + return ( + + + + Редактирование медиа + + + + + + + setMediaName(e.target.value)} + label="Название медиа" + disabled={isLoading} + /> + setMediaFilename(e.target.value)} + label="Название файла" + disabled={isLoading} + /> + + + + Тип медиа + + + + + + + + + + + {/* Only show "Replace file" button if no new file is currently selected */} + + + + {error && ( + + {error} + + )} + {success && ( + + Медиа успешно сохранено + + )} + + + + setError(null)} + > + setError(null)}> + {error} + + + + setSuccess(false)} + > + setSuccess(false)}> + Медиа успешно сохранено + + + + ); +}); diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx new file mode 100644 index 0000000..1a68b7f --- /dev/null +++ b/src/pages/MapPage/index.tsx @@ -0,0 +1,1594 @@ +import React, { + useEffect, + useRef, + useState, + useCallback, + ReactNode, +} from "react"; +import { Map, View, Overlay, MapBrowserEvent } from "ol"; +import TileLayer from "ol/layer/Tile"; +import OSM from "ol/source/OSM"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource, { VectorSourceEvent } from "ol/source/Vector"; +import { Draw, Modify, Select } from "ol/interaction"; +import { DrawEvent } from "ol/interaction/Draw"; +import { ModifyEvent } from "ol/interaction/Modify"; +import { SelectEvent } from "ol/interaction/Select"; +import { Style, Fill, Stroke, Circle as CircleStyle } from "ol/style"; +import { Point, LineString, Geometry } from "ol/geom"; +import { transform } from "ol/proj"; +import { GeoJSON } from "ol/format"; +import { Bus, RouteIcon, MapPin, Trash2, ArrowRightLeft } from "lucide-react"; +import { altKeyOnly, primaryAction, singleClick } from "ol/events/condition"; +import { Feature } from "ol"; +import Layer from "ol/layer/Layer"; +import Source from "ol/source/Source"; +import LayerRenderer from "ol/renderer/Layer"; +import { FeatureLike } from "ol/Feature"; + +// --- CONFIGURATION --- +export const mapConfig = { + center: [37.6173, 55.7558] as [number, number], + zoom: 10, +}; + +// --- SVG ICONS --- +const EditIcon = () => ( + + + +); +const StatsIcon = () => ( + + + +); +const LineIconSvg = () => ( + + + +); + +// --- TYPE DEFINITIONS --- +interface MapServiceConfig { + target: HTMLElement; + center: [number, number]; + zoom: number; +} + +interface HistoryState { + action: string; + state: string; // GeoJSON string +} + +// Internal OL type extensions for better type safety with private/protected members + +class MapService { + private map: Map | null; + private vectorSource: VectorSource>; + private vectorLayer: VectorLayer>>; + private tooltipElement: HTMLElement; + private tooltipOverlay: Overlay | null; + private mode: string | null; + private currentDrawingType: string | null; + private currentInteraction: Draw | null; + private modifyInteraction: Modify; + private selectInteraction: Select; + private infoSelectedFeatureId: string | number | null; + private hoveredFeatureId: string | number | null; + private history: HistoryState[]; + private historyIndex: number; + private beforeModifyState: string | null; // GeoJSON string + private boundHandlePointerMove: ( + event: MapBrowserEvent + ) => void; + private boundHandlePointerLeave: () => void; + private boundHandleContextMenu: (event: MouseEvent) => void; + private boundHandleKeyDown: (event: KeyboardEvent) => void; + + // Styles + private defaultStyle: Style; + private selectedStyle: Style; + private drawStyle: Style; + private infoSelectedLineStyle: Style; + private busIconStyle: Style; + private selectedBusIconStyle: Style; + private drawBusIconStyle: Style; + private infoSelectedBusIconStyle: Style; + private universalHoverStylePoint: Style; + private universalHoverStyleLine: Style; + + // Callbacks + private setCoordinatesPanelContent: (content: string) => void; + private setShowCoordinatesPanel: (show: boolean) => void; + private setLoading: (loading: boolean) => void; + private setError: (error: string | null) => void; + private onModeChangeCallback: (mode: string) => void; + private onFeaturesChange: (features: Feature[]) => void; + private onFeatureSelect: (feature: Feature | null) => void; + + constructor( + config: MapServiceConfig, + setCoordinatesPanelContent: (content: string) => void, + setShowCoordinatesPanel: (show: boolean) => void, + setLoading: (loading: boolean) => void, + setError: (error: string | null) => void, + onModeChangeCallback: (mode: string) => void, + onFeaturesChange: (features: Feature[]) => void, + onFeatureSelect: (feature: Feature | null) => void, + tooltipElement: HTMLElement + ) { + this.map = null; + this.tooltipElement = tooltipElement; + this.tooltipOverlay = null; + this.mode = null; + this.currentDrawingType = null; + this.currentInteraction = null; + this.infoSelectedFeatureId = null; + this.hoveredFeatureId = null; + this.history = []; + this.historyIndex = -1; + this.beforeModifyState = null; + + this.setCoordinatesPanelContent = setCoordinatesPanelContent; + this.setShowCoordinatesPanel = setShowCoordinatesPanel; + this.setLoading = setLoading; + this.setError = setError; + this.onModeChangeCallback = onModeChangeCallback; + this.onFeaturesChange = onFeaturesChange; + this.onFeatureSelect = onFeatureSelect; + + this.defaultStyle = new Style({ + fill: new Fill({ color: "rgba(66, 153, 225, 0.2)" }), + stroke: new Stroke({ color: "#3182ce", width: 2 }), + }); + this.selectedStyle = new Style({ + fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }), + stroke: new Stroke({ color: "#dd6b20", width: 3 }), + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: "#dd6b20" }), + stroke: new Stroke({ color: "white", width: 1.5 }), + }), + }); + this.drawStyle = new Style({ + fill: new Fill({ color: "rgba(74, 222, 128, 0.3)" }), + stroke: new Stroke({ + color: "rgba(34, 197, 94, 0.7)", + width: 2, + lineDash: [5, 5], + }), + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: "rgba(34, 197, 94, 0.7)" }), + }), + }); + this.infoSelectedLineStyle = new Style({ + fill: new Fill({ color: "rgba(255, 0, 0, 0.2)" }), + stroke: new Stroke({ color: "rgba(255, 0, 0, 0.9)", width: 3 }), + }); + this.busIconStyle = new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: "rgba(0, 60, 255, 0.8)" }), + stroke: new Stroke({ color: "#ffffff", width: 1.5 }), + }), + }); + this.selectedBusIconStyle = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }), + stroke: new Stroke({ color: "#ffffff", width: 2 }), + }), + }); + this.drawBusIconStyle = new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }), + stroke: new Stroke({ color: "#ffffff", width: 1.5 }), + }), + }); + this.infoSelectedBusIconStyle = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(255, 0, 0, 0.9)" }), + stroke: new Stroke({ color: "#ffffff", width: 2 }), + }), + }); + this.universalHoverStylePoint = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }), + }), + }); + this.universalHoverStyleLine = new Style({ + fill: new Fill({ color: "rgba(255, 165, 0, 0.3)" }), + stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 3.5 }), + }); + + this.vectorSource = new VectorSource>(); + this.vectorLayer = new VectorLayer({ + source: this.vectorSource, + style: (featureLike: FeatureLike) => { + const feature = featureLike as Feature; // We know our source has Features + const geometryType = feature.getGeometry()?.getType(); + const fId = feature.getId(); + const isEditSelected = this.selectInteraction + ?.getFeatures() + .getArray() + .includes(feature); + const isInfoSelected = + this.infoSelectedFeatureId === fId && this.mode === "statistics"; + const isHovered = this.hoveredFeatureId === fId; + + if (geometryType === "Point") { + if (isHovered && !isEditSelected && !isInfoSelected) + return this.universalHoverStylePoint; + if (isInfoSelected) return this.infoSelectedBusIconStyle; + return isEditSelected ? this.selectedBusIconStyle : this.busIconStyle; + } else if (geometryType === "LineString") { + if (isHovered && !isEditSelected && !isInfoSelected) + return this.universalHoverStyleLine; + if (isInfoSelected) return this.infoSelectedLineStyle; + return isEditSelected ? this.selectedStyle : this.defaultStyle; + } + return this.defaultStyle; + }, + }); + + this.boundHandlePointerMove = this.handlePointerMove.bind(this); + this.boundHandlePointerLeave = this.handlePointerLeave.bind(this); + this.boundHandleContextMenu = this.handleContextMenu.bind(this); + this.boundHandleKeyDown = this.handleKeyDown.bind(this); + + this.vectorSource.on( + "addfeature", + this.handleFeatureEvent.bind(this) as ( + event: VectorSourceEvent> + ) => void + ); + this.vectorSource.on("removefeature", () => this.updateFeaturesInReact()); + this.vectorSource.on( + "changefeature", + this.handleFeatureChange.bind(this) as ( + event: VectorSourceEvent> + ) => void + ); + + let renderCompleteHandled = false; + const MAP_LOAD_TIMEOUT = 15000; + try { + this.map = new Map({ + target: config.target, + layers: [new TileLayer({ source: new OSM() }), this.vectorLayer], + view: new View({ + center: transform(config.center, "EPSG:4326", "EPSG:3857"), + zoom: config.zoom, + }), + controls: [], + }); + if (this.tooltipElement && this.map) { + this.tooltipOverlay = new Overlay({ + element: this.tooltipElement, + offset: [15, 0], + positioning: "center-left", + }); + this.map.addOverlay(this.tooltipOverlay); + } + this.map.once("rendercomplete", () => { + if (!renderCompleteHandled) { + this.setLoading(false); + renderCompleteHandled = true; + this.setError(null); + } + }); + setTimeout(() => { + if (!renderCompleteHandled && this.map) { + this.setLoading(false); + this.setError("Карта не загрузилась вовремя."); + renderCompleteHandled = true; + } + }, MAP_LOAD_TIMEOUT); + } catch (error) { + this.setError("Критическая ошибка при инициализации карты."); + this.setLoading(false); + renderCompleteHandled = true; + } + + this.modifyInteraction = new Modify({ + source: this.vectorSource, + style: (f: FeatureLike) => + f.getGeometry()?.getType() === "Point" + ? this.selectedBusIconStyle + : this.selectedStyle, + deleteCondition: ( + e: MapBrowserEvent + ) => + altKeyOnly(e as MapBrowserEvent) && + primaryAction(e as MapBrowserEvent), + }); + + this.selectInteraction = new Select({ + style: (featureLike: FeatureLike) => { + if (!featureLike || !featureLike.getGeometry) return this.defaultStyle; + const feature = featureLike as Feature; + const geometryType = feature.getGeometry()?.getType(); + return geometryType === "Point" + ? this.selectedBusIconStyle + : this.selectedStyle; + }, + condition: ( + e: MapBrowserEvent + ) => { + const isEdit = this.mode === "edit"; + const isSingle = singleClick(e as MapBrowserEvent); + if (!isEdit || !isSingle) return false; + + let clickModify = false; + if (this.modifyInteraction.getActive() && this.map) { + const px = e.pixel; + const internalModify = this.modifyInteraction as Modify; + const sketchFs: Feature[] | undefined = ( + internalModify as unknown as { + overlay_?: VectorLayer>>; + } + ).overlay_ + ?.getSource() + ?.getFeatures(); + + if (sketchFs) { + for (const sf of sketchFs) { + const g = sf.getGeometry(); + if (g) { + const coord = this.map.getCoordinateFromPixel(px); + if (!coord) continue; + const cp = g.getClosestPoint(coord); + const cppx = this.map.getPixelFromCoordinate(cp); + if (!cppx) continue; + const pixelTolerance = + (internalModify as unknown as { pixelTolerance_?: number }) + .pixelTolerance_ || 10; + if ( + Math.sqrt((px[0] - cppx[0]) ** 2 + (px[1] - cppx[1]) ** 2) < + pixelTolerance + 2 + ) { + clickModify = true; + break; + } + } + } + } + } + return !clickModify; + }, + filter: ( + _: FeatureLike, + l: Layer< + Source, + LayerRenderer>>> + > | null + ) => l === this.vectorLayer, + }); + + this.modifyInteraction.on("modifystart", () => { + const geoJSONFormat = new GeoJSON(); + if (!this.map) return; + this.beforeModifyState = geoJSONFormat.writeFeatures( + this.vectorSource.getFeatures(), + { + dataProjection: "EPSG:4326", + featureProjection: this.map.getView().getProjection().getCode(), + } + ); + }); + + this.modifyInteraction.on("modifyend", (event: ModifyEvent) => { + if (this.beforeModifyState) { + this.addStateToHistory("modify-before", this.beforeModifyState); + this.beforeModifyState = null; + } + this.updateFeaturesInReact(); + event.features.forEach((f) => { + if (f instanceof Feature) { + // Ensure f is a Feature instance + this.getFeatureCoordinates(f as Feature); + } + }); + }); + + if (this.map) { + this.map.addInteraction(this.modifyInteraction); + this.map.addInteraction(this.selectInteraction); + this.modifyInteraction.setActive(false); + this.selectInteraction.setActive(false); + + this.selectInteraction.on("select", (e: SelectEvent) => { + if (this.mode === "edit") { + this.infoSelectedFeatureId = null; + this.vectorLayer.changed(); + const selFs = e.selected as Feature[]; // Selected features are always ol/Feature + this.modifyInteraction.setActive(selFs.length > 0); + if (selFs.length > 0) { + this.getFeatureCoordinates(selFs[0]); + this.onFeatureSelect(selFs[0]); + } else { + this.hideCoordinatesPanel(); + this.onFeatureSelect(null); + } + } else if (this.mode === "statistics") { + this.onFeatureSelect(null); // Clear selection when switching to stats mode via click + } + }); + this.map.on("click", this.handleClick.bind(this) as any); + this.map.on("pointermove", this.boundHandlePointerMove as any); + const targetEl = this.map.getTargetElement(); + if (targetEl instanceof HTMLElement) { + targetEl.addEventListener("contextmenu", this.boundHandleContextMenu); + targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave); + } + document.addEventListener("keydown", this.boundHandleKeyDown); + this.activateEditMode(); + } + } + + private addStateToHistory( + actionDescription: string, + stateToSave: string + ): void { + this.history = this.history.slice(0, this.historyIndex + 1); + this.history.push({ action: actionDescription, state: stateToSave }); + this.historyIndex = this.history.length - 1; + } + + private getCurrentStateAsGeoJSON(): string | null { + if (!this.map) return null; + const geoJSONFormat = new GeoJSON(); + return geoJSONFormat.writeFeatures(this.vectorSource.getFeatures(), { + dataProjection: "EPSG:4326", + featureProjection: this.map.getView().getProjection().getCode(), + }); + } + + public undo(): void { + if (this.historyIndex >= 0) { + const stateToRestore = this.history[this.historyIndex].state; + this.applyHistoryState(stateToRestore); + this.historyIndex--; + } else { + this.vectorSource.clear(); + this.updateFeaturesInReact(); + this.onFeatureSelect(null); + this.hideCoordinatesPanel(); + } + } + + public redo(): void { + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++; + this.applyHistoryState(this.history[this.historyIndex].state); + } + } + + private applyHistoryState(geoJSONState: string): void { + if (!this.map) return; + const geoJSONFormat = new GeoJSON(); + const features = geoJSONFormat.readFeatures(geoJSONState, { + dataProjection: "EPSG:4326", + featureProjection: this.map.getView().getProjection().getCode(), + }) as Feature[]; + this.vectorSource.clear(); + if (features.length > 0) this.vectorSource.addFeatures(features); + this.updateFeaturesInReact(); + this.onFeatureSelect(null); + this.hideCoordinatesPanel(); + this.selectInteraction.getFeatures().clear(); + this.infoSelectedFeatureId = null; + this.vectorLayer.changed(); + } + + private updateFeaturesInReact(): void { + if (this.onFeaturesChange) { + this.onFeaturesChange(this.vectorSource.getFeatures()); + } + } + + private handlePointerLeave(): void { + if (this.hoveredFeatureId) { + this.hoveredFeatureId = null; + this.vectorLayer.changed(); + } + if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); + } + + private handleKeyDown(event: KeyboardEvent): void { + if ((event.ctrlKey || event.metaKey) && event.key === "z") { + event.preventDefault(); + this.undo(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key === "y") { + event.preventDefault(); + this.redo(); + return; + } + if (event.key === "Escape") { + if (this.mode && this.mode.startsWith("drawing-")) this.finishDrawing(); + else if ( + this.mode === "edit" && + this.selectInteraction?.getFeatures().getLength() > 0 + ) { + this.selectInteraction.getFeatures().clear(); + // Firing an event for the selection change might be good here + // So that the UI (like coordinates panel) updates + this.onFeatureSelect(null); + this.hideCoordinatesPanel(); + } else if (this.mode === "statistics" && this.infoSelectedFeatureId) { + this.infoSelectedFeatureId = null; + this.vectorLayer.changed(); + this.hideCoordinatesPanel(); + this.onFeatureSelect(null); + } + } + } + + private setMode(newMode: string): void { + if (!this.map) return; + const oldMode = this.mode; + this.mode = newMode; + + if (this.hoveredFeatureId && oldMode !== newMode) { + this.hoveredFeatureId = null; + this.vectorLayer.changed(); + if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); + } + + if (newMode !== "statistics" && this.infoSelectedFeatureId) { + this.infoSelectedFeatureId = null; + this.vectorLayer.changed(); + } + + if (this.onModeChangeCallback) this.onModeChangeCallback(newMode); + + if (this.currentInteraction instanceof Draw) { + this.map.removeInteraction(this.currentInteraction); + this.currentInteraction = null; + } + + if (newMode === "edit") { + this.selectInteraction.setActive(true); + } else { + this.selectInteraction.getFeatures().clear(); + this.selectInteraction.setActive(false); + this.modifyInteraction.setActive(false); + } + + const isEditWithSelection = + newMode === "edit" && + this.selectInteraction?.getFeatures().getLength() > 0; + const isStatsWithSelection = + newMode === "statistics" && this.infoSelectedFeatureId; + + if (!isEditWithSelection && !isStatsWithSelection) { + this.hideCoordinatesPanel(); + } + } + + public activateEditMode(): void { + this.currentDrawingType = null; + this.setMode("edit"); + if (this.selectInteraction.getFeatures().getLength() > 0) { + const firstSelectedFeature = this.selectInteraction + .getFeatures() + .item(0) as Feature; + this.getFeatureCoordinates(firstSelectedFeature); + } else { + this.hideCoordinatesPanel(); + } + } + + public activateStatisticsMode(): void { + this.currentDrawingType = null; + this.setMode("statistics"); + if (this.selectInteraction?.getFeatures().getLength() > 0) { + this.selectInteraction.getFeatures().clear(); + } + if (this.infoSelectedFeatureId) { + const feature = this.vectorSource.getFeatureById( + this.infoSelectedFeatureId + ); + if (feature) this.getFeatureCoordinates(feature); + } else { + this.hideCoordinatesPanel(); + } + } + + public startDrawing(type: "Point" | "LineString", options = {}): void { + if (!this.map) return; + this.currentDrawingType = type; + const drawingMode = `drawing-${type.toLowerCase()}`; + this.setMode(drawingMode); + if (this.currentInteraction instanceof Draw) { + this.map.removeInteraction(this.currentInteraction); + } + + this.currentInteraction = new Draw({ + source: this.vectorSource, + type, + style: type === "Point" ? this.drawBusIconStyle : this.drawStyle, + ...options, + }); + + let stateBeforeDraw: string | null = null; + this.currentInteraction.on("drawstart", () => { + stateBeforeDraw = this.getCurrentStateAsGeoJSON(); + }); + + this.currentInteraction.on("drawend", (event: DrawEvent) => { + if (stateBeforeDraw) { + this.addStateToHistory("draw-before", stateBeforeDraw); + } + const feature = event.feature as Feature; // DrawEvent feature is always an ol/Feature + const geometry = feature.getGeometry(); + if (!geometry) return; + const geometryType = geometry.getType(); + let baseName = ""; + let namePrefix = ""; + + if (geometryType === "Point") { + baseName = "Станция"; + namePrefix = "Станция "; + } else if (geometryType === "LineString") { + baseName = "Маршрут"; + namePrefix = "Маршрут "; + } else { + baseName = "Объект"; + namePrefix = "Объект "; + } + + const existingNamedFeatures = this.vectorSource + .getFeatures() + .filter( + (f) => + f !== feature && + f.getGeometry()?.getType() === geometryType && + f.get("name") && + (f.get("name") as string).startsWith(namePrefix) + ); + + let maxNumber = 0; + existingNamedFeatures.forEach((f) => { + const name = f.get("name") as string; + if (name) { + const numStr = name.substring(namePrefix.length); + const num = parseInt(numStr, 10); + if (!isNaN(num) && num > maxNumber) { + maxNumber = num; + } + } + }); + + const newNumber = maxNumber + 1; + feature.set("name", `${baseName} ${newNumber}`); + feature.setStyle( + type === "Point" ? this.busIconStyle : this.defaultStyle + ); + if (type === "LineString") this.finishDrawing(); + }); + this.map.addInteraction(this.currentInteraction); + } + + public startDrawingMarker(): void { + this.startDrawing("Point"); + } + + public startDrawingLine(): void { + this.startDrawing("LineString"); + } + + public finishDrawing(): void { + if (!this.map || !this.currentInteraction) return; + + const drawInteraction = this.currentInteraction as Draw; + if ( + (drawInteraction as unknown as { sketchFeature_?: Feature }) + .sketchFeature_ + ) { + try { + // This can throw if not enough points for geometry (e.g. LineString) + this.currentInteraction.finishDrawing(); + } catch (e) { + // console.warn("Could not finish drawing programmatically:", e); + // If finishDrawing fails (e.g. LineString with one point), + // we still want to remove interaction and reset mode. + } + } + this.map.removeInteraction(this.currentInteraction); + this.currentInteraction = null; + this.currentDrawingType = null; + this.activateEditMode(); + } + + private handleContextMenu(event: MouseEvent): void { + event.preventDefault(); + if ( + !this.map || + !( + this.mode && + this.mode.startsWith("drawing-") && + this.currentInteraction instanceof Draw + ) + ) { + return; + } + + const drawInteraction = this.currentInteraction as Draw; + if ( + this.currentDrawingType === "LineString" && + (drawInteraction as unknown as { sketchFeature_?: Feature }) + .sketchFeature_ + ) { + try { + this.currentInteraction.finishDrawing(); + } catch (e) { + this.finishDrawing(); // Fallback to ensure cleanup + } + } else { + this.finishDrawing(); + } + } + + private handlePointerMove(event: MapBrowserEvent): void { + if (!this.map || event.dragging) { + if (this.hoveredFeatureId) { + this.hoveredFeatureId = null; + this.vectorLayer.changed(); + } + if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); + return; + } + + const pixel = this.map.getEventPixel(event.originalEvent as PointerEvent); + const featureAtPixel: Feature | undefined = + this.map.forEachFeatureAtPixel( + pixel, + (f: FeatureLike) => f as Feature, // Cast is okay due to layerFilter + { + layerFilter: (l) => l === this.vectorLayer, + hitTolerance: 5, + } + ); + + const newHoveredFeatureId = featureAtPixel + ? featureAtPixel.getId() + : (null as string | number | null); + + if (this.tooltipOverlay && this.tooltipElement) { + if ( + this.mode === "statistics" && + featureAtPixel && + featureAtPixel.getGeometry()?.getType() === "Point" + ) { + this.tooltipElement.innerHTML = + (featureAtPixel.get("name") as string) || "Станция"; + this.tooltipOverlay.setPosition(event.coordinate); + } else { + this.tooltipOverlay.setPosition(undefined); + } + } + + if (this.hoveredFeatureId !== newHoveredFeatureId) { + this.hoveredFeatureId = newHoveredFeatureId as string | number | null; + this.vectorLayer.changed(); + } + } + + private handleClick(event: MapBrowserEvent): void { + if (!this.map || (this.mode && this.mode.startsWith("drawing-"))) return; + + const featureAtPixel: Feature | undefined = + this.map.forEachFeatureAtPixel( + event.pixel, + (f: FeatureLike) => f as Feature, + { + layerFilter: (l) => l === this.vectorLayer, + hitTolerance: 2, // Lower tolerance for click + } + ); + + if (this.mode === "statistics") { + this.selectInteraction.getFeatures().clear(); // Ensure no edit selection in stats mode + if (featureAtPixel) { + const featureId = featureAtPixel.getId(); + if (this.infoSelectedFeatureId !== featureId) { + this.infoSelectedFeatureId = featureId as string | number | null; + this.getFeatureCoordinates(featureAtPixel); + this.onFeatureSelect(featureAtPixel); + } else { + // Clicked same feature again, deselect + this.infoSelectedFeatureId = null; + this.hideCoordinatesPanel(); + this.onFeatureSelect(null); + } + } else { + // Clicked on map, not on a feature + this.infoSelectedFeatureId = null; + this.hideCoordinatesPanel(); + this.onFeatureSelect(null); + } + this.vectorLayer.changed(); // Re-render for style changes + } else if (this.mode === "edit") { + // Selection in edit mode is handled by the Select interaction's 'select' event + // This click handler can ensure that if a click happens outside any feature + // (and doesn't trigger a new selection by the Select interaction), + // any info selection from a previous mode is cleared. + if (this.infoSelectedFeatureId) { + this.infoSelectedFeatureId = null; + this.vectorLayer.changed(); + } + // If click was on a feature, Select interaction handles it. + // If click was on map, Select interaction's 'select' event with e.deselected might clear panel. + // If no feature is selected after this click, the coordinates panel should be hidden by Select's logic. + if ( + !featureAtPixel && + this.selectInteraction.getFeatures().getLength() === 0 + ) { + this.hideCoordinatesPanel(); + this.onFeatureSelect(null); + } + } + } + + public selectFeature(featureId: string | number | undefined): void { + if (!this.map || featureId === undefined) { + this.onFeatureSelect(null); + if (this.mode === "edit") this.selectInteraction.getFeatures().clear(); + if (this.mode === "statistics" && this.infoSelectedFeatureId) { + this.infoSelectedFeatureId = null; + this.vectorLayer.changed(); + } + this.hideCoordinatesPanel(); + return; + } + + const feature = this.vectorSource.getFeatureById(featureId); + + if (!feature) { + this.onFeatureSelect(null); + if (this.mode === "edit") this.selectInteraction.getFeatures().clear(); + if ( + this.mode === "statistics" && + this.infoSelectedFeatureId === featureId + ) { + this.infoSelectedFeatureId = null; + } + this.hideCoordinatesPanel(); + this.vectorLayer.changed(); + return; + } + + if (this.mode === "edit") { + this.infoSelectedFeatureId = null; // Clear any info selection + this.selectInteraction.getFeatures().clear(); + this.selectInteraction.getFeatures().push(feature); + // The 'select' event on selectInteraction should handle getFeatureCoordinates and onFeatureSelect + } else if (this.mode === "statistics") { + this.selectInteraction.getFeatures().clear(); // Clear any edit selection + this.infoSelectedFeatureId = featureId; + this.getFeatureCoordinates(feature); + this.onFeatureSelect(feature); + } + this.vectorLayer.changed(); + + const view = this.map.getView(); + const geometry = feature.getGeometry(); + if (geometry) { + if (geometry.getType() === "Point") { + const pointGeom = geometry as Point; + view.animate({ + center: pointGeom.getCoordinates(), + duration: 500, + zoom: Math.max(view.getZoom() || mapConfig.zoom, 14), + }); + } else { + view.fit(geometry.getExtent(), { + duration: 500, + padding: [50, 50, 50, 50], + maxZoom: 15, + }); + } + } + } + + public deleteFeature(featureId: string | number | undefined): void { + if (featureId === undefined) return; + const feature = this.vectorSource.getFeatureById(featureId); + if (feature) { + const currentState = this.getCurrentStateAsGeoJSON(); + if (currentState) { + this.addStateToHistory("delete", currentState); + } + + if (this.infoSelectedFeatureId === featureId) { + this.infoSelectedFeatureId = null; + this.onFeatureSelect(null); // Notify React that selection changed + } + + const selectedFeaturesCollection = this.selectInteraction?.getFeatures(); + if (selectedFeaturesCollection?.getArray().includes(feature)) { + selectedFeaturesCollection.clear(); + // This will trigger selectInteraction's 'select' event with deselected, + // which should handle UI updates like hiding coordinates panel. + this.onFeatureSelect(null); // Explicitly notify too + } + + this.vectorSource.removeFeature(feature); // This will trigger 'removefeature' event + + // If after deletion, no feature is selected in any mode, hide panel + if ( + !this.infoSelectedFeatureId && + selectedFeaturesCollection?.getLength() === 0 + ) { + this.hideCoordinatesPanel(); + } + this.vectorLayer.changed(); // Ensure map re-renders + } + } + + private getFeatureCoordinates(feature: Feature): void { + if (!feature || !feature.getGeometry()) { + this.hideCoordinatesPanel(); + return; + } + const geometry = feature.getGeometry(); + if (!geometry) { + this.hideCoordinatesPanel(); + return; + } + + const toGeoCoords = (c: number[]) => transform(c, "EPSG:3857", "EPSG:4326"); + const fmt = new Intl.NumberFormat("ru-RU", { + minimumFractionDigits: 5, + maximumFractionDigits: 5, + }); + let txt = ""; + const fType = geometry.getType(); + const fName = + (feature.get("name") as string) || + (fType === "Point" + ? "Станция" + : fType === "LineString" + ? "Маршрут" + : "Объект"); + + let nameClr = "text-blue-600"; + if ( + this.mode === "statistics" && + this.infoSelectedFeatureId === feature.getId() + ) { + nameClr = "text-red-600"; + } else if ( + this.mode === "edit" && + this.selectInteraction?.getFeatures().getArray().includes(feature) + ) { + nameClr = "text-orange-600"; + } + txt += `${fName}:
`; + + if (geometry instanceof Point) { + const crds = geometry.getCoordinates(); + const [lon, lat] = toGeoCoords(crds); + txt += `Шир: ${fmt.format(lat)}, Дол: ${fmt.format(lon)}`; + } else if (geometry instanceof LineString) { + const crdsArr = geometry.getCoordinates(); + txt += crdsArr + .map((c, i) => { + const [lon, lat] = toGeoCoords(c); + return `Т${i + 1}: Ш: ${fmt.format( + lat + )}, Д: ${fmt.format(lon)}`; + }) + .join("
"); + const lenM = geometry.getLength(); + txt += `
Длина: ${lenM.toFixed(2)} м`; + } else { + txt += "Координаты недоступны."; + } + + this.setCoordinatesPanelContent(txt); + this.setShowCoordinatesPanel(true); + } + + private hideCoordinatesPanel(): void { + this.setCoordinatesPanelContent(""); + this.setShowCoordinatesPanel(false); + } + + public getAllFeaturesAsGeoJSON(): string | null { + if (!this.vectorSource || !this.map) return null; + const feats = this.vectorSource.getFeatures(); + if (feats.length === 0) return null; + + const geoJSONFmt = new GeoJSON(); + // Clone features to avoid transforming original geometries + const featsExp = feats.map((f) => { + const cF = f.clone(); + // Make sure to copy properties and ID + cF.setProperties(f.getProperties(), true); // true to suppress change event + cF.setId(f.getId()); + const geom = cF.getGeometry(); + if (geom) { + geom.transform(this.map!.getView().getProjection(), "EPSG:4326"); + } + return cF; + }); + return geoJSONFmt.writeFeatures(featsExp); + } + + public destroy(): void { + if (this.map) { + document.removeEventListener("keydown", this.boundHandleKeyDown); + const targetEl = this.map.getTargetElement(); + if (targetEl instanceof HTMLElement) { + targetEl.removeEventListener( + "contextmenu", + this.boundHandleContextMenu + ); + targetEl.removeEventListener( + "pointerleave", + this.boundHandlePointerLeave + ); + } + this.map.un("pointermove", this.boundHandlePointerMove as any); + if (this.tooltipOverlay) this.map.removeOverlay(this.tooltipOverlay); + this.vectorSource.clear(); + this.map.setTarget(undefined); // Use undefined instead of null + this.map = null; + } + } + + private handleFeatureEvent( + event: VectorSourceEvent> + ): void { + if (!event.feature) return; + const feature = event.feature; + if (!feature.getId()) { + feature.setId(Date.now() + Math.random().toString(36).substr(2, 9)); + } + this.updateFeaturesInReact(); + } + + private handleFeatureChange( + event: VectorSourceEvent> + ): void { + if (!event.feature) return; + const feature = event.feature; + this.updateFeaturesInReact(); + + const selectedInEdit = this.selectInteraction + ?.getFeatures() + .getArray() + .includes(feature); + const selectedInInfo = this.infoSelectedFeatureId === feature.getId(); + + if ( + (this.mode === "edit" && selectedInEdit) || + (this.mode === "statistics" && selectedInInfo) + ) { + this.getFeatureCoordinates(feature); + } + } +} + +// --- MAP CONTROLS COMPONENT --- +interface MapControlsProps { + mapService: MapService | null; + activeMode: string; +} + +const MapControls: React.FC = ({ + mapService, + activeMode, +}) => { + if (!mapService) return null; + + const controls = [ + { + mode: "edit", + title: "Редакт.", + longTitle: "Редактирование", + icon: , + action: () => mapService.activateEditMode(), + }, + { + mode: "statistics", + title: "Инфо", + longTitle: "Информация", + icon: , + action: () => mapService.activateStatisticsMode(), + }, + { + mode: "drawing-point", + title: "Станция", + longTitle: "Добавить станцию", + icon: , + action: () => mapService.startDrawingMarker(), + }, + { + mode: "drawing-linestring", + title: "Маршрут", + longTitle: "Добавить маршрут", + icon: , + action: () => mapService.startDrawingLine(), + }, + ]; + return ( +
+ {controls.map((c) => ( + + ))} +
+ ); +}; + +// --- MAP SIGHTBAR COMPONENT --- +interface MapSightbarProps { + mapService: MapService | null; + mapFeatures: Feature[]; + selectedFeature: Feature | null; + currentMapMode: string; +} + +const MapSightbar: React.FC = ({ + mapService, + mapFeatures, + selectedFeature, + currentMapMode, +}) => { + const [activeSection, setActiveSection] = useState("layers"); + const toggleSection = (id: string) => + setActiveSection(activeSection === id ? null : id); + + const handleFeatureClick = useCallback( + (id: string | number | undefined) => { + if (mapService) mapService.selectFeature(id); + }, + [mapService] + ); + + const handleDeleteFeature = useCallback( + (id: string | number | undefined) => { + if ( + mapService && + window.confirm("Вы действительно хотите удалить этот объект?") + ) + mapService.deleteFeature(id); + }, + [mapService] + ); + + const stations = mapFeatures.filter( + (f) => f.getGeometry()?.getType() === "Point" + ); + const lines = mapFeatures.filter( + (f) => f.getGeometry()?.getType() === "LineString" + ); + + interface SidebarSection { + id: string; + title: string; + icon: ReactNode; + content: ReactNode; + } + + const sections: SidebarSection[] = [ + { + id: "layers", + title: `Остановки (${stations.length})`, + icon: , + content: ( +
+ {stations.length > 0 ? ( + stations.map((s) => { + const sId = s.getId(); + const sName = (s.get("name") as string) || "Без названия"; + const isSelectedInCurrentMode = selectedFeature?.getId() === sId; + const sGeom = s.getGeometry(); + let sCoordsText: string | null = null; + + if (sGeom instanceof Point) { + const sC = transform( + sGeom.getCoordinates(), + "EPSG:3857", + "EPSG:4326" + ); + const cF = new Intl.NumberFormat("ru-RU", { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + }); + sCoordsText = `${cF.format(sC[1])}, ${cF.format(sC[0])}`; + } + + return ( +
handleFeatureClick(sId)} + > +
+
+ + + {sName} + +
+ {sCoordsText && ( +

+ {sCoordsText} +

+ )} +
+ +
+ ); + }) + ) : ( +

Нет добавленных остановок.

+ )} +
+ ), + }, + { + id: "lines", + title: `Маршруты (${lines.length})`, + icon: , + content: ( +
+ {lines.length > 0 ? ( + lines.map((l) => { + const lId = l.getId(); + const lName = (l.get("name") as string) || "Без названия"; + const isSelectedInCurrentMode = selectedFeature?.getId() === lId; + + return ( +
handleFeatureClick(lId)} + > +
+ + + {lName} + +
+ +
+ ); + }) + ) : ( +

Нет добавленных маршрутов.

+ )} +
+ ), + }, + ]; + + if (!sections.length && activeSection) { + setActiveSection(null); + } else if ( + sections.length > 0 && + !sections.find((s) => s.id === activeSection) + ) { + setActiveSection(sections[0]?.id || null); + } + + return ( +
+
+

Панель управления

+
+
+ {sections.map((s) => ( +
+ +
+
+ {s.content} +
+
+
+ ))} +
+
+ ); +}; + +// --- MAP PAGE COMPONENT --- +export const MapPage: React.FC = () => { + const mapRef = useRef(null); + const tooltipRef = useRef(null); + const [mapServiceInstance, setMapServiceInstance] = + useState(null); + const [coordinatesPanelContent, setCoordinatesPanelContent] = + useState(""); + const [showCoordinatesPanel, setShowCoordinatesPanel] = + useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentMapMode, setCurrentMapMode] = useState("edit"); // Default mode + const [mapFeatures, setMapFeatures] = useState[]>([]); + const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] = + useState | null>(null); + + const handleFeaturesChange = useCallback( + (feats: Feature[]) => setMapFeatures([...feats]), // Create new array instance + [] + ); + + const handleFeatureSelectForSidebar = useCallback( + (feat: Feature | null) => { + setSelectedFeatureForSidebar(feat); + if (!feat && showCoordinatesPanel) { + // Check showCoordinatesPanel state before calling setter + setShowCoordinatesPanel(false); + } + }, + [showCoordinatesPanel] // Dependency on showCoordinatesPanel as it's read + ); + + useEffect(() => { + let service: MapService | null = null; // Initialize to null + if (mapRef.current && tooltipRef.current && !mapServiceInstance) { + setIsLoading(true); + setError(null); + try { + service = new MapService( + { ...mapConfig, target: mapRef.current }, + setCoordinatesPanelContent, + setShowCoordinatesPanel, + setIsLoading, + setError, + setCurrentMapMode, + handleFeaturesChange, + handleFeatureSelectForSidebar, + tooltipRef.current + ); + setMapServiceInstance(service); + } catch (e: any) { + console.error("MapPage useEffect error:", e); + setError( + `Ошибка инициализации карты: ${ + e.message || "Неизвестная ошибка" + }. Пожалуйста, проверьте консоль.` + ); + setIsLoading(false); + } + } + return () => { + if (service) { + service.destroy(); + setMapServiceInstance(null); // Ensure instance is cleared on unmount + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Added stable useCallback refs + + return ( +
+
+
+ + + {isLoading && ( +
+
+
+ Загрузка карты... +
+
+ )} + {error && !isLoading && ( +
+

Произошла ошибка

+

{error}

+ +
+ )} +
+ {mapServiceInstance && !isLoading && !error && ( + + )} +
+
+
+
+ {mapServiceInstance && !isLoading && !error && ( + + )} +
+ ); +}; diff --git a/src/pages/MediaListPage/index.tsx b/src/pages/MediaListPage/index.tsx new file mode 100644 index 0000000..5497d0b --- /dev/null +++ b/src/pages/MediaListPage/index.tsx @@ -0,0 +1,76 @@ +import { Button, TableBody } from "@mui/material"; +import { TableRow, TableCell } from "@mui/material"; +import { Table, TableHead } from "@mui/material"; +import { mediaStore, MEDIA_TYPE_LABELS } from "@shared"; +import { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { Eye, Pencil, Trash2 } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +const rows = (media: any[]) => { + return media.map((row) => ({ + id: row.id, + media_name: row.media_name, + media_type: + MEDIA_TYPE_LABELS[row.media_type as keyof typeof MEDIA_TYPE_LABELS], + })); +}; + +export const MediaListPage = observer(() => { + const { media, getMedia, deleteMedia } = mediaStore; + const navigate = useNavigate(); + useEffect(() => { + getMedia(); + }, []); + + const currentRows = rows(media); + + return ( + <> +
+ +
+ + + + Название + Тип + Действия + + + + {currentRows.map((row) => ( + + {row.media_name} + {row.media_type} + +
+ + + + + +
+
+
+ ))} +
+
+ + ); +}); diff --git a/src/pages/PreviewMediaPage/index.tsx b/src/pages/PreviewMediaPage/index.tsx new file mode 100644 index 0000000..e9b2417 --- /dev/null +++ b/src/pages/PreviewMediaPage/index.tsx @@ -0,0 +1,44 @@ +import { mediaStore } from "@shared"; +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { MediaViewer } from "../../widgets/MediaViewer/index"; +import { observer } from "mobx-react-lite"; +import { Download } from "lucide-react"; +import { Button } from "@mui/material"; + +export const PreviewMediaPage = observer(() => { + const { id } = useParams(); + const { oneMedia, getOneMedia } = mediaStore; + + useEffect(() => { + getOneMedia(id!); + }, []); + + return ( +
+
+ +
+ + {oneMedia && ( +
+

+ Чтобы скачать файл, нажмите на кнопку ниже +

+ +
+ )} +
+ ); +}); diff --git a/src/pages/index.ts b/src/pages/index.ts index 838ea4c..2116f1c 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -4,3 +4,8 @@ export * from "./LoginPage"; export * from "./DevicesPage"; export * from "./SightPage"; export * from "./CreateSightPage"; +export * from "./MapPage"; +export * from "./MediaListPage"; +export * from "./PreviewMediaPage"; +export * from "./EditMediaPage"; +// export * from "./CreateMediaPage"; diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx index db715fe..a4d8194 100644 --- a/src/shared/config/constants.tsx +++ b/src/shared/config/constants.tsx @@ -1,5 +1,13 @@ import { authStore } from "@shared"; -import { Power, LucideIcon, Building2, MonitorSmartphone } from "lucide-react"; +import { + Power, + LucideIcon, + Building2, + MonitorSmartphone, + Map, + BookImage, + Newspaper, +} from "lucide-react"; export const DRAWER_WIDTH = 300; interface NavigationItem { @@ -21,12 +29,30 @@ export const NAVIGATION_ITEMS: { icon: Building2, path: "/sight", }, + { + id: "map", + label: "Карта", + icon: Map, + path: "/map", + }, { id: "devices", label: "Устройства", icon: MonitorSmartphone, path: "/devices", }, + { + id: "media", + label: "Медиа", + icon: BookImage, + path: "/media", + }, + { + id: "articles", + label: "Статьи", + icon: Newspaper, + path: "/articles", + }, ], secondary: [ { diff --git a/src/shared/store/MediaStore/index.tsx b/src/shared/store/MediaStore/index.tsx index c06f8b6..1a05074 100644 --- a/src/shared/store/MediaStore/index.tsx +++ b/src/shared/store/MediaStore/index.tsx @@ -10,7 +10,7 @@ type Media = { class MediaStore { media: Media[] = []; - + oneMedia: Media | null = null; constructor() { makeAutoObservable(this); } @@ -22,6 +22,60 @@ class MediaStore { this.media = [...response.data]; }); }; + + deleteMedia = async (id: string) => { + await authInstance.delete(`/media/${id}`); + this.media = this.media.filter((media) => media.id !== id); + }; + + getOneMedia = async (id: string) => { + this.oneMedia = null; + const response = await authInstance.get(`/media/${id}`); + runInAction(() => { + this.oneMedia = response.data; + }); + }; + + updateMedia = async (id: string, data: Partial) => { + const response = await authInstance.patch(`/media/${id}`, data); + runInAction(() => { + // Update in media array + const index = this.media.findIndex((m) => m.id === id); + if (index !== -1) { + this.media[index] = { ...this.media[index], ...response.data }; + } + // Update oneMedia if it's the current media being viewed + if (this.oneMedia?.id === id) { + this.oneMedia = { ...this.oneMedia, ...response.data }; + } + }); + return response.data; + }; + + updateMediaFile = async (id: string, file: File, filename: string) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("filename", filename); + + const response = await authInstance.patch(`/media/${id}/file`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + runInAction(() => { + // Update in media array + const index = this.media.findIndex((m) => m.id === id); + if (index !== -1) { + this.media[index] = { ...this.media[index], ...response.data }; + } + // Update oneMedia if it's the current media being viewed + if (this.oneMedia?.id === id) { + this.oneMedia = { ...this.oneMedia, ...response.data }; + } + }); + return response.data; + }; } export const mediaStore = new MediaStore(); diff --git a/src/shared/store/VehicleStore/index.ts b/src/shared/store/VehicleStore/index.ts index 01e2c2a..63856ab 100644 --- a/src/shared/store/VehicleStore/index.ts +++ b/src/shared/store/VehicleStore/index.ts @@ -1,7 +1,7 @@ import { API_URL, authInstance } from "@shared"; import { makeAutoObservable } from "mobx"; -type Vehicle = { +export type Vehicle = { vehicle: { id: number; tail_number: number; diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index 58cceaf..242991e 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -11,15 +11,25 @@ import { devicesStore, Modal, snapshotStore, - vehicleStore, -} from "@shared"; + vehicleStore, // Not directly used in this component's rendering logic anymore +} from "@shared"; // Assuming @shared exports these import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Button, Checkbox, Typography } from "@mui/material"; // Import Typography for the modal message +import { Button, Checkbox, Typography } from "@mui/material"; +import { Vehicle } from "@shared"; +import { toast } from "react-toastify"; -const formatDate = (dateString: string | undefined) => { +export type ConnectedDevice = string; + +interface Snapshot { + ID: string; // Assuming ID is string based on usage + Name: string; + // Add other snapshot properties if needed +} + +// --- HELPER FUNCTIONS --- +const formatDate = (dateString: string | null) => { if (!dateString) return "Нет данных"; - try { const date = new Date(dateString); return new Intl.DateTimeFormat("ru-RU", { @@ -32,244 +42,368 @@ const formatDate = (dateString: string | undefined) => { hour12: false, }).format(date); } catch (error) { - console.error("Error formatting date:", error); return "Некорректная дата"; } }; +type TableRowData = { + tail_number: number; + online: boolean; + lastUpdate: string | null; + gps: boolean; + media: boolean; + connection: boolean; + device_uuid: string | null; +}; function createData( - uuid: string, + tail_number: number, online: boolean, - lastUpdate: string, + lastUpdate: string | null, gps: boolean, media: boolean, - connection: boolean -) { - return { uuid, online, lastUpdate, gps, media, connection }; + connection: boolean, + device_uuid: string | null +): TableRowData { + return { + tail_number, + online, + lastUpdate, + gps, + media, + connection, + device_uuid, + }; } -// Keep the rows function as you provided it, without additional filters -const rows = (vehicles: any[]) => { +// This function transforms the raw device data (which includes vehicle and device_status) +// into the format expected by the table. It now filters for devices that have a UUID. +const transformDevicesToRows = ( + vehicles: Vehicle[] + // devices: ConnectedDevice[] +): TableRowData[] => { return vehicles.map((vehicle) => { + const uuid = vehicle.vehicle.uuid; + if (!uuid) + return { + tail_number: vehicle.vehicle.tail_number, + online: false, + lastUpdate: null, + gps: false, + media: false, + connection: false, + device_uuid: null, + }; return createData( - vehicle?.vehicle?.tail_number ?? "1243000", // Using tail_number as UUID, as in your original code - vehicle?.device_status?.online ?? false, - vehicle?.device_status?.last_update, - vehicle?.device_status?.gps_ok, - vehicle?.device_status?.media_service_ok, - vehicle?.device_status?.is_connected + vehicle.vehicle.tail_number, + vehicle.device_status?.online ?? false, + vehicle.device_status?.last_update ?? null, + vehicle.device_status?.gps_ok ?? false, + vehicle.device_status?.media_service_ok ?? false, + vehicle.device_status?.is_connected ?? false, + uuid ); }); }; export const DevicesTable = observer(() => { const { - devices, getDevices, - // uuid, // This 'uuid' from devicesStore refers to a *single* selected device, not for batch actions. - setSelectedDevice, // Useful for individual device actions like 'Reload Status' + setSelectedDevice, sendSnapshotModalOpen, toggleSendSnapshotModal, } = devicesStore; - const { snapshots, getSnapshots } = snapshotStore; - const { getVehicles } = vehicleStore; - const [selectedDevices, setSelectedDevices] = useState([]); - // Get the current list of rows displayed in the table - const currentRows = rows(devices); + const { snapshots, getSnapshots } = snapshotStore; + const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth + const { devices } = devicesStore; + const [selectedDeviceUuids, setSelectedDeviceUuids] = useState([]); + + // 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. + const currentTableRows = transformDevicesToRows( + vehicles as Vehicle[] + // devices as ConnectedDevice[] + ); useEffect(() => { const fetchData = async () => { - await getVehicles(); - await getDevices(); + await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices + await getDevices(); // This should fetch the combined vehicle/device_status data await getSnapshots(); }; fetchData(); - }, []); + }, [getDevices, getSnapshots]); // Added dependencies - // Determine if all visible devices are selected const isAllSelected = - currentRows.length > 0 && selectedDevices.length === currentRows.length; + currentTableRows.length > 0 && + selectedDeviceUuids.length === currentTableRows.length; const handleSelectAllDevices = () => { if (isAllSelected) { - // If all are currently selected, deselect all - setSelectedDevices([]); + setSelectedDeviceUuids([]); } else { - // Otherwise, select all device UUIDs from the current rows - setSelectedDevices(currentRows.map((row) => row.uuid)); + // Select all device UUIDs from the *currently visible and selectable* rows + setSelectedDeviceUuids( + currentTableRows.map((row) => row.device_uuid ?? "") + ); } }; - const handleSelectDevice = (event: React.ChangeEvent) => { - const deviceUuid = event.target.value; + const handleSelectDevice = ( + event: React.ChangeEvent, + deviceUuid: string + ) => { if (event.target.checked) { - setSelectedDevices((prevSelected) => [...prevSelected, deviceUuid]); + setSelectedDeviceUuids((prevSelected) => [...prevSelected, deviceUuid]); } else { - setSelectedDevices((prevSelected) => + setSelectedDeviceUuids((prevSelected) => prevSelected.filter((uuid) => uuid !== deviceUuid) ); } }; - // This function now opens the modal for selected devices const handleOpenSendSnapshotModal = () => { - if (selectedDevices.length > 0) { + if (selectedDeviceUuids.length > 0) { toggleSendSnapshotModal(); } }; const handleReloadStatus = async (uuid: string) => { - setSelectedDevice(uuid); // Set the active device in store for context if needed - await authInstance.post(`/devices/${uuid}/request-status`); - await getDevices(); // Refresh devices after status request + setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere + try { + await authInstance.post(`/devices/${uuid}/request-status`); + await getDevices(); // Refresh devices to show updated status + } catch (error) { + console.error(`Error requesting status for device ${uuid}:`, error); + // Optionally: show a user-facing error message + } }; - // This function now handles sending snapshots to ALL selected devices const handleSendSnapshotAction = async (snapshotId: string) => { + if (selectedDeviceUuids.length === 0) return; + try { - for (const deviceUuid of selectedDevices) { + // Create an array of promises for all snapshot requests + const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`); - // Ensure you are using the correct API endpoint for force-snapshot - await authInstance.post(`/devices/${deviceUuid}/force-snapshot`, { + return authInstance.post(`/devices/${deviceUuid}/force-snapshot`, { snapshot_id: snapshotId, }); - } - // After all requests are sent - await getDevices(); // Refresh the device list to show updated status - setSelectedDevices([]); // Clear the selection + }); + + // Wait for all promises to settle (either resolve or reject) + await Promise.allSettled(snapshotPromises); + + // After all requests are attempted + await getDevices(); // Refresh the device list + setSelectedDeviceUuids([]); // Clear the selection toggleSendSnapshotModal(); // Close the modal } catch (error) { - console.error("Error sending snapshots:", error); - // You might want to show an error notification to the user here + // This catch block might not be hit if Promise.allSettled is used, + // as it doesn't reject on individual promise failures. + // Individual errors should be handled if needed within the .map or by checking results. + console.error("Error in snapshot sending process:", error); } }; return ( <> - -
- {" "} - {/* Changed gap to 3 for slightly less space */} + +
- +
- - {" "} - {/* Added padding="checkbox" */} + 0 && + selectedDeviceUuids.length < currentTableRows.length + } checked={isAllSelected} onChange={handleSelectAllDevices} inputProps={{ "aria-label": "select all devices" }} + size="small" /> - Бортовой номер + Борт. номер Онлайн - Последнее обновление - ГПС - Медиа-данные - Подключение - Перезапросить + Обновлено + GPS + Медиа + Связь + Действия - {currentRows.map((row) => ( + {currentTableRows.map((row) => ( { + // Allow clicking row to toggle checkbox, if not clicking on button + if ( + (event.target as HTMLElement).closest("button") === null && + (event.target as HTMLElement).closest( + 'input[type="checkbox"]' + ) === null + ) { + handleSelectDevice( + { + target: { + checked: !selectedDeviceUuids.includes( + row.device_uuid ?? "" + ), + }, + } as React.ChangeEvent, // Simulate event + row.device_uuid ?? "" + ); + } + }} + sx={{ + cursor: "pointer", + "&:last-child td, &:last-child th": { border: 0 }, + }} > - - {" "} - {/* Added padding="checkbox" */} + + handleSelectDevice(event, row.device_uuid ?? "") + } + size="small" /> - - - {row.uuid} + + {row.tail_number} - {row.online ? ( - - ) : ( - - )} +
+ {row.online ? ( + + ) : ( + + )} +
- {formatDate(row.lastUpdate)} +
+ {formatDate(row.lastUpdate)} +
- {row.gps ? ( - - ) : ( - - )} +
+ {row.gps ? ( + + ) : ( + + )} +
- {row.media ? ( - - ) : ( - - )} +
+ {row.media ? ( + + ) : ( + + )} +
- {row.connection ? ( - - ) : ( - - )} +
+ {row.connection ? ( + + ) : ( + + )} +
- +
))} + {currentTableRows.length === 0 && ( + + + Нет устройств для отображения. + + + )}
+ - - Выбрать снапшот для{" "} - {selectedDevices.length}{" "} - устройств + + Отправить снапшот -
- {snapshots && snapshots.length > 0 ? ( - snapshots.map((snapshot) => ( + + Выбрано устройств:{" "} + + {selectedDeviceUuids.length} + + +
+ {snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots + (snapshots as Snapshot[]).map((snapshot) => ( @@ -280,6 +414,15 @@ export const DevicesTable = observer(() => { )}
+ ); diff --git a/src/widgets/MediaViewer/index.tsx b/src/widgets/MediaViewer/index.tsx index 0a4a9ed..dc7902f 100644 --- a/src/widgets/MediaViewer/index.tsx +++ b/src/widgets/MediaViewer/index.tsx @@ -9,7 +9,10 @@ export interface MediaData { filename?: string; } -export function MediaViewer({ media }: Readonly<{ media?: MediaData }>) { +export function MediaViewer({ + media, + className, +}: Readonly<{ media?: MediaData; className?: string }>) { const token = localStorage.getItem("token"); return ( ) { justifyContent: "center", margin: "0 auto", }} + className={className} > {media?.media_type === 1 && (