Add Map page with basic logic
				
					
				
			This commit is contained in:
		| @@ -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 = () => { | ||||
|         <Route path="sight/:id" element={<EditSightPage />} /> | ||||
|         <Route path="sight/create" element={<CreateSightPage />} /> | ||||
|         <Route path="devices" element={<DevicesPage />} /> | ||||
|         <Route path="map" element={<MapPage />} /> | ||||
|         <Route path="media" element={<MediaListPage />} /> | ||||
|         <Route path="media/:id" element={<PreviewMediaPage />} /> | ||||
|         <Route path="media/:id/edit" element={<EditMediaPage />} /> | ||||
|         {/* <Route path="media/create" element={<CreateMediaPage />} /> */} | ||||
|       </Route> | ||||
|     </Routes> | ||||
|   ); | ||||
|   | ||||
							
								
								
									
										126
									
								
								src/pages/CreateMediaPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/pages/CreateMediaPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string | null>(null); | ||||
| //   const [success, setSuccess] = useState(false); | ||||
| //   const fileInputRef = useRef<HTMLInputElement>(null); | ||||
| //   const [uploadDialogOpen, setUploadDialogOpen] = useState(false); | ||||
|  | ||||
| //   const handleDrop = (e: DragEvent<HTMLDivElement>) => { | ||||
| //     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<HTMLDivElement>) => { | ||||
| //     e.preventDefault(); | ||||
| //     setIsDragging(true); | ||||
| //   }; | ||||
|  | ||||
| //   const handleDragLeave = () => { | ||||
| //     setIsDragging(false); | ||||
| //   }; | ||||
|  | ||||
| //   const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
| //     const files = e.target.files; | ||||
| //     if (files && files.length > 0) { | ||||
| //       editSightStore.fileToUpload = files[0]; | ||||
| //       setUploadDialogOpen(true); | ||||
| //     } | ||||
| //   }; | ||||
|  | ||||
| //   const handleUploadSuccess = () => { | ||||
| //     setSuccess(true); | ||||
| //     setUploadDialogOpen(false); | ||||
| //   }; | ||||
|  | ||||
| //   return ( | ||||
| //     <div className="w-full h-full p-8"> | ||||
| //       <div className="flex items-center gap-4 mb-8"> | ||||
| //         <Button | ||||
| //           variant="outlined" | ||||
| //           startIcon={<ArrowLeft size={20} />} | ||||
| //           onClick={() => navigate("/media")} | ||||
| //         > | ||||
| //           Назад | ||||
| //         </Button> | ||||
| //         <Typography variant="h5">Загрузка медиафайла</Typography> | ||||
| //       </div> | ||||
|  | ||||
| //       <Paper | ||||
| //         elevation={3} | ||||
| //         className={`w-full h-[60vh] flex flex-col items-center justify-center p-8 transition-colors ${ | ||||
| //           isDragging ? "bg-blue-50 border-2 border-blue-500" : "bg-gray-50" | ||||
| //         }`} | ||||
| //         onDrop={handleDrop} | ||||
| //         onDragOver={handleDragOver} | ||||
| //         onDragLeave={handleDragLeave} | ||||
| //       > | ||||
| //         <input | ||||
| //           type="file" | ||||
| //           ref={fileInputRef} | ||||
| //           className="hidden" | ||||
| //           onChange={handleFileSelect} | ||||
| //           accept="image/*,video/*,.glb,.gltf" | ||||
| //         /> | ||||
|  | ||||
| //         <Box className="flex flex-col items-center gap-4 text-center"> | ||||
| //           <Upload size={48} className="text-gray-400" /> | ||||
| //           <Typography variant="h6" className="text-gray-600"> | ||||
| //             Перетащите файл сюда или | ||||
| //           </Typography> | ||||
| //           <Button | ||||
| //             variant="contained" | ||||
| //             onClick={() => fileInputRef.current?.click()} | ||||
| //             startIcon={<Upload size={20} />} | ||||
| //           > | ||||
| //             Выберите файл | ||||
| //           </Button> | ||||
| //           <Typography variant="body2" className="text-gray-500 mt-4"> | ||||
| //             Поддерживаемые форматы: JPG, PNG, GIF, MP4, WebM, GLB, GLTF | ||||
| //           </Typography> | ||||
| //         </Box> | ||||
| //       </Paper> | ||||
|  | ||||
| //       <UploadMediaDialog | ||||
| //         open={uploadDialogOpen} | ||||
| //         onClose={() => setUploadDialogOpen(false)} | ||||
| //         afterUpload={handleUploadSuccess} | ||||
| //       /> | ||||
|  | ||||
| //       <Snackbar | ||||
| //         open={success} | ||||
| //         autoHideDuration={2000} | ||||
| //         onClose={() => setSuccess(false)} | ||||
| //       > | ||||
| //         <Alert severity="success" onClose={() => setSuccess(false)}> | ||||
| //           Медиафайл успешно загружен | ||||
| //         </Alert> | ||||
| //       </Snackbar> | ||||
|  | ||||
| //       <Snackbar | ||||
| //         open={!!error} | ||||
| //         autoHideDuration={6000} | ||||
| //         onClose={() => setError(null)} | ||||
| //       > | ||||
| //         <Alert severity="error" onClose={() => setError(null)}> | ||||
| //           {error} | ||||
| //         </Alert> | ||||
| //       </Snackbar> | ||||
| //     </div> | ||||
| //   ); | ||||
| // }); | ||||
							
								
								
									
										255
									
								
								src/pages/EditMediaPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								src/pages/EditMediaPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string | null>(null); | ||||
|   const [success, setSuccess] = useState(false); | ||||
|  | ||||
|   const fileInputRef = useRef<HTMLInputElement>(null); | ||||
|   const [newFile, setNewFile] = useState<File | null>(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<HTMLDivElement>) => { | ||||
|   //   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<HTMLDivElement>) => { | ||||
|   //   e.preventDefault(); | ||||
|   //   setIsDragging(true); | ||||
|   // }; | ||||
|  | ||||
|   // const handleDragLeave = () => { | ||||
|   //   setIsDragging(false); | ||||
|   // }; | ||||
|  | ||||
|   const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     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 ( | ||||
|       <Box className="flex justify-center items-center h-screen"> | ||||
|         <CircularProgress /> | ||||
|       </Box> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box className="p-6 max-w-4xl mx-auto"> | ||||
|       <Box className="flex items-center gap-4 mb-6"> | ||||
|         <Button | ||||
|           variant="outlined" | ||||
|           startIcon={<ArrowLeft size={20} />} | ||||
|           onClick={() => navigate("/media")} | ||||
|         > | ||||
|           Назад | ||||
|         </Button> | ||||
|         <Typography variant="h5">Редактирование медиа</Typography> | ||||
|       </Box> | ||||
|  | ||||
|       <Paper className="p-6"> | ||||
|         <input | ||||
|           type="file" | ||||
|           ref={fileInputRef} | ||||
|           className="hidden" | ||||
|           onChange={handleFileSelect} | ||||
|           accept="image/*,video/*,.glb,.gltf" | ||||
|         /> | ||||
|         <Box className="flex flex-col gap-6"> | ||||
|           <Box className="flex gap-4"> | ||||
|             <TextField | ||||
|               fullWidth | ||||
|               value={mediaName} | ||||
|               onChange={(e) => setMediaName(e.target.value)} | ||||
|               label="Название медиа" | ||||
|               disabled={isLoading} | ||||
|             /> | ||||
|             <TextField | ||||
|               fullWidth | ||||
|               value={mediaFilename} | ||||
|               onChange={(e) => setMediaFilename(e.target.value)} | ||||
|               label="Название файла" | ||||
|               disabled={isLoading} | ||||
|             /> | ||||
|           </Box> | ||||
|  | ||||
|           <FormControl fullWidth sx={{ width: "50%" }}> | ||||
|             <InputLabel>Тип медиа</InputLabel> | ||||
|             <Select | ||||
|               value={mediaType} | ||||
|               label="Тип медиа" | ||||
|               onChange={(e) => setMediaType(Number(e.target.value))} | ||||
|               disabled={isLoading} | ||||
|             > | ||||
|               {Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => ( | ||||
|                 <MenuItem key={type} value={Number(type)}> | ||||
|                   {label} | ||||
|                 </MenuItem> | ||||
|               ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|  | ||||
|           <Box className="flex gap-6"> | ||||
|             <Paper | ||||
|               elevation={2} | ||||
|               sx={{ | ||||
|                 flex: 1, | ||||
|                 p: 2, | ||||
|                 display: "flex", | ||||
|                 alignItems: "center", | ||||
|                 justifyContent: "center", | ||||
|                 minHeight: 400, | ||||
|               }} | ||||
|             > | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: id || "", | ||||
|                   media_type: mediaType, | ||||
|                   filename: mediaFilename, | ||||
|                 }} | ||||
|               /> | ||||
|             </Paper> | ||||
|  | ||||
|             <Box className="flex flex-col gap-4 self-end"> | ||||
|               <Button | ||||
|                 variant="contained" | ||||
|                 color="primary" | ||||
|                 startIcon={<Save size={20} />} | ||||
|                 onClick={handleSave} | ||||
|                 disabled={isLoading || (!mediaName && !mediaFilename)} | ||||
|               > | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Сохранить"} | ||||
|               </Button> | ||||
|               {/* Only show "Replace file" button if no new file is currently selected */} | ||||
|             </Box> | ||||
|           </Box> | ||||
|  | ||||
|           {error && ( | ||||
|             <Typography color="error" className="mt-2"> | ||||
|               {error} | ||||
|             </Typography> | ||||
|           )} | ||||
|           {success && ( | ||||
|             <Typography color="success.main" className="mt-2"> | ||||
|               Медиа успешно сохранено | ||||
|             </Typography> | ||||
|           )} | ||||
|         </Box> | ||||
|       </Paper> | ||||
|  | ||||
|       <Snackbar | ||||
|         open={!!error} | ||||
|         autoHideDuration={6000} | ||||
|         onClose={() => setError(null)} | ||||
|       > | ||||
|         <Alert severity="error" onClose={() => setError(null)}> | ||||
|           {error} | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|  | ||||
|       <Snackbar | ||||
|         open={success} | ||||
|         autoHideDuration={2000} | ||||
|         onClose={() => setSuccess(false)} | ||||
|       > | ||||
|         <Alert severity="success" onClose={() => setSuccess(false)}> | ||||
|           Медиа успешно сохранено | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|     </Box> | ||||
|   ); | ||||
| }); | ||||
							
								
								
									
										1594
									
								
								src/pages/MapPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1594
									
								
								src/pages/MapPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										76
									
								
								src/pages/MediaListPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/pages/MediaListPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <> | ||||
|       <div className="flex justify-end p-3 gap-5"> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           onClick={() => navigate("/sight/create")} | ||||
|         > | ||||
|           Создать | ||||
|         </Button> | ||||
|       </div> | ||||
|       <Table sx={{ minWidth: 650 }} aria-label="simple table"> | ||||
|         <TableHead> | ||||
|           <TableRow> | ||||
|             <TableCell align="center">Название</TableCell> | ||||
|             <TableCell align="center">Тип</TableCell> | ||||
|             <TableCell align="center">Действия</TableCell> | ||||
|           </TableRow> | ||||
|         </TableHead> | ||||
|         <TableBody> | ||||
|           {currentRows.map((row) => ( | ||||
|             <TableRow | ||||
|               key={row.media_name} | ||||
|               sx={{ "&:last-child td, &:last-child th": { border: 0 } }} | ||||
|             > | ||||
|               <TableCell align="center">{row.media_name}</TableCell> | ||||
|               <TableCell align="center">{row.media_type}</TableCell> | ||||
|               <TableCell align="center"> | ||||
|                 <div className="flex gap-7 justify-center"> | ||||
|                   <button onClick={() => navigate(`/media/${row.id}`)}> | ||||
|                     <Eye size={20} className="text-green-500" /> | ||||
|                   </button> | ||||
|  | ||||
|                   <button onClick={() => navigate(`/media/${row.id}/edit`)}> | ||||
|                     <Pencil size={20} className="text-blue-500" /> | ||||
|                   </button> | ||||
|  | ||||
|                   <button onClick={() => deleteMedia(row.id)}> | ||||
|                     <Trash2 size={20} className="text-red-500" /> | ||||
|                   </button> | ||||
|                 </div> | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
|           ))} | ||||
|         </TableBody> | ||||
|       </Table> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
							
								
								
									
										44
									
								
								src/pages/PreviewMediaPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/pages/PreviewMediaPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4"> | ||||
|       <div className="w-full h-full flex justify-center items-center"> | ||||
|         <MediaViewer className="w-full h-full" media={oneMedia!} /> | ||||
|       </div> | ||||
|  | ||||
|       {oneMedia && ( | ||||
|         <div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md"> | ||||
|           <p className="text-white text-center"> | ||||
|             Чтобы скачать файл, нажмите на кнопку ниже | ||||
|           </p> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             startIcon={<Download size={16} />} | ||||
|             component="a" | ||||
|             href={`${ | ||||
|               import.meta.env.VITE_KRBL_MEDIA | ||||
|             }${id}/download?token=${localStorage.getItem("token")}`} | ||||
|             target="_blank" | ||||
|           > | ||||
|             Скачать | ||||
|           </Button> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
| @@ -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"; | ||||
|   | ||||
| @@ -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: [ | ||||
|     { | ||||
|   | ||||
| @@ -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<Media>) => { | ||||
|     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(); | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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<string[]>([]); | ||||
|  | ||||
|   // 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<string[]>([]); | ||||
|  | ||||
|   // 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<HTMLInputElement>) => { | ||||
|     const deviceUuid = event.target.value; | ||||
|   const handleSelectDevice = ( | ||||
|     event: React.ChangeEvent<HTMLInputElement>, | ||||
|     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 ( | ||||
|     <> | ||||
|       <TableContainer component={Paper}> | ||||
|         <div className="flex justify-end p-3 gap-3"> | ||||
|           {" "} | ||||
|           {/* Changed gap to 3 for slightly less space */} | ||||
|       <TableContainer component={Paper} sx={{ mt: 2 }}> | ||||
|         <div className="flex justify-end p-3 gap-2"> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             variant="outlined" // Changed to outlined for distinction | ||||
|             onClick={handleSelectAllDevices} | ||||
|             size="small" | ||||
|           > | ||||
|             {isAllSelected ? "Снять выбор со всех" : "Выбрать все"} | ||||
|             {isAllSelected ? "Снять выбор" : "Выбрать все"} | ||||
|           </Button> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             disabled={selectedDevices.length === 0} | ||||
|             onClick={handleOpenSendSnapshotModal} // Call the new handler | ||||
|             disabled={selectedDeviceUuids.length === 0} | ||||
|             onClick={handleOpenSendSnapshotModal} | ||||
|             size="small" | ||||
|           > | ||||
|             Отправить снапшот ({selectedDevices.length}) | ||||
|             Отправить снапшот ({selectedDeviceUuids.length}) | ||||
|           </Button> | ||||
|         </div> | ||||
|         <Table sx={{ minWidth: 650 }} aria-label="simple table"> | ||||
|         <Table sx={{ minWidth: 650 }} aria-label="devices table" size="small"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell align="center" padding="checkbox"> | ||||
|                 {" "} | ||||
|                 {/* Added padding="checkbox" */} | ||||
|               <TableCell padding="checkbox"> | ||||
|                 <Checkbox | ||||
|                   indeterminate={ | ||||
|                     selectedDeviceUuids.length > 0 && | ||||
|                     selectedDeviceUuids.length < currentTableRows.length | ||||
|                   } | ||||
|                   checked={isAllSelected} | ||||
|                   onChange={handleSelectAllDevices} | ||||
|                   inputProps={{ "aria-label": "select all devices" }} | ||||
|                   size="small" | ||||
|                 /> | ||||
|               </TableCell> | ||||
|               <TableCell align="center">Бортовой номер</TableCell> | ||||
|               <TableCell align="center">Борт. номер</TableCell> | ||||
|               <TableCell align="center">Онлайн</TableCell> | ||||
|               <TableCell align="center">Последнее обновление</TableCell> | ||||
|               <TableCell align="center">ГПС</TableCell> | ||||
|               <TableCell align="center">Медиа-данные</TableCell> | ||||
|               <TableCell align="center">Подключение</TableCell> | ||||
|               <TableCell align="center">Перезапросить</TableCell> | ||||
|               <TableCell align="center">Обновлено</TableCell> | ||||
|               <TableCell align="center">GPS</TableCell> | ||||
|               <TableCell align="center">Медиа</TableCell> | ||||
|               <TableCell align="center">Связь</TableCell> | ||||
|               <TableCell align="center">Действия</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {currentRows.map((row) => ( | ||||
|             {currentTableRows.map((row) => ( | ||||
|               <TableRow | ||||
|                 key={row.uuid} // Use row.uuid as key for consistent rendering | ||||
|                 sx={{ "&:last-child td, &:last-child th": { border: 0 } }} | ||||
|                 key={row.tail_number} | ||||
|                 hover | ||||
|                 role="checkbox" | ||||
|                 aria-checked={selectedDeviceUuids.includes( | ||||
|                   row.device_uuid ?? "" | ||||
|                 )} | ||||
|                 selected={selectedDeviceUuids.includes(row.device_uuid ?? "")} | ||||
|                 onClick={(event) => { | ||||
|                   // 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<HTMLInputElement>, // Simulate event | ||||
|                       row.device_uuid ?? "" | ||||
|                     ); | ||||
|                   } | ||||
|                 }} | ||||
|                 sx={{ | ||||
|                   cursor: "pointer", | ||||
|                   "&:last-child td, &:last-child th": { border: 0 }, | ||||
|                 }} | ||||
|               > | ||||
|                 <TableCell align="center" padding="checkbox"> | ||||
|                   {" "} | ||||
|                   {/* Added padding="checkbox" */} | ||||
|                 <TableCell padding="checkbox"> | ||||
|                   <Checkbox | ||||
|                     className="h-full" | ||||
|                     onChange={handleSelectDevice} | ||||
|                     value={row.uuid} | ||||
|                     checked={selectedDevices.includes(row.uuid)} // THIS IS THE KEY CHANGE | ||||
|                     checked={selectedDeviceUuids.includes( | ||||
|                       row.device_uuid ?? "" | ||||
|                     )} | ||||
|                     onChange={(event) => | ||||
|                       handleSelectDevice(event, row.device_uuid ?? "") | ||||
|                     } | ||||
|                     size="small" | ||||
|                   /> | ||||
|                 </TableCell> | ||||
|  | ||||
|                 <TableCell align="center" component="th" scope="row"> | ||||
|                   {row.uuid} | ||||
|                 <TableCell | ||||
|                   align="center" | ||||
|                   component="th" | ||||
|                   scope="row" | ||||
|                   id={`device-label-${row.device_uuid}`} | ||||
|                 > | ||||
|                   {row.tail_number} | ||||
|                 </TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   {row.online ? ( | ||||
|                     <Check className="m-auto text-green-500" /> | ||||
|                   ) : ( | ||||
|                     <X className="m-auto text-red-500" /> | ||||
|                   )} | ||||
|                   <div className="flex items-center justify-center"> | ||||
|                     {row.online ? ( | ||||
|                       <Check size={18} className="text-green-600" /> | ||||
|                     ) : ( | ||||
|                       <X size={18} className="text-red-600" /> | ||||
|                     )} | ||||
|                   </div> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   {formatDate(row.lastUpdate)} | ||||
|                   <div className="flex items-center justify-center"> | ||||
|                     {formatDate(row.lastUpdate)} | ||||
|                   </div> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   {row.gps ? ( | ||||
|                     <Check className="m-auto text-green-500" /> | ||||
|                   ) : ( | ||||
|                     <X className="m-auto text-red-500" /> | ||||
|                   )} | ||||
|                   <div className="flex items-center justify-center"> | ||||
|                     {row.gps ? ( | ||||
|                       <Check size={18} className="text-green-600" /> | ||||
|                     ) : ( | ||||
|                       <X size={18} className="text-red-600" /> | ||||
|                     )} | ||||
|                   </div> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   {row.media ? ( | ||||
|                     <Check className="m-auto text-green-500" /> | ||||
|                   ) : ( | ||||
|                     <X className="m-auto text-red-500" /> | ||||
|                   )} | ||||
|                   <div className="flex items-center justify-center"> | ||||
|                     {row.media ? ( | ||||
|                       <Check size={18} className="text-green-600" /> | ||||
|                     ) : ( | ||||
|                       <X size={18} className="text-red-600" /> | ||||
|                     )} | ||||
|                   </div> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   {row.connection ? ( | ||||
|                     <Check className="m-auto text-green-500" /> | ||||
|                   ) : ( | ||||
|                     <X className="m-auto text-red-500" /> | ||||
|                   )} | ||||
|                   <div className="flex items-center justify-center"> | ||||
|                     {row.connection ? ( | ||||
|                       <Check size={18} className="text-green-600" /> | ||||
|                     ) : ( | ||||
|                       <X size={18} className="text-red-600" /> | ||||
|                     )} | ||||
|                   </div> | ||||
|                 </TableCell> | ||||
|                 <TableCell align="center"> | ||||
|                   <button onClick={() => handleReloadStatus(row.uuid)}> | ||||
|                     <RotateCcw className="m-auto" /> | ||||
|                   </button> | ||||
|                   <Button | ||||
|                     onClick={async (e) => { | ||||
|                       e.stopPropagation(); | ||||
|                       try { | ||||
|                         if ( | ||||
|                           row.device_uuid && | ||||
|                           devices.find((device) => device === row.device_uuid) | ||||
|                         ) { | ||||
|                           await handleReloadStatus(row.device_uuid); | ||||
|                           await getDevices(); | ||||
|                           toast.success("Статус устройства обновлен"); | ||||
|                         } else { | ||||
|                           toast.error("Нет связи с устройством"); | ||||
|                         } | ||||
|                       } catch (error) { | ||||
|                         toast.error("Ошибка сервера"); | ||||
|                       } | ||||
|                     }} | ||||
|                     title="Перезапросить статус" | ||||
|                     size="small" | ||||
|                     variant="text" | ||||
|                   > | ||||
|                     <RotateCcw size={16} /> | ||||
|                   </Button> | ||||
|                 </TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|             {currentTableRows.length === 0 && ( | ||||
|               <TableRow> | ||||
|                 <TableCell colSpan={8} align="center"> | ||||
|                   Нет устройств для отображения. | ||||
|                 </TableCell> | ||||
|               </TableRow> | ||||
|             )} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|  | ||||
|       <Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}> | ||||
|         <Typography variant="h6" component="p" sx={{ mb: 2 }}> | ||||
|           Выбрать снапшот для{" "} | ||||
|           <strong className="text-blue-600">{selectedDevices.length}</strong>{" "} | ||||
|           устройств | ||||
|         <Typography variant="h6" component="h2" sx={{ mb: 1 }}> | ||||
|           Отправить снапшот | ||||
|         </Typography> | ||||
|         <div className="mt-5 flex flex-col gap-2 max-h-[300px] overflow-y-auto"> | ||||
|           {snapshots && snapshots.length > 0 ? ( | ||||
|             snapshots.map((snapshot) => ( | ||||
|         <Typography variant="body1" sx={{ mb: 2 }}> | ||||
|           Выбрано устройств:{" "} | ||||
|           <strong className="text-blue-600"> | ||||
|             {selectedDeviceUuids.length} | ||||
|           </strong> | ||||
|         </Typography> | ||||
|         <div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2"> | ||||
|           {snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots | ||||
|             (snapshots as Snapshot[]).map((snapshot) => ( | ||||
|               <Button | ||||
|                 variant="outlined" | ||||
|                 fullWidth | ||||
|                 onClick={() => handleSendSnapshotAction(snapshot.ID)} | ||||
|                 sx={{ | ||||
|                   p: 1.5, // Adjust padding | ||||
|                   borderRadius: 2, // Adjust border radius | ||||
|                   backgroundColor: "white", // Ensure background is white | ||||
|                   "&:hover": { | ||||
|                     backgroundColor: "grey.100", // Light hover effect | ||||
|                   }, | ||||
|                 }} | ||||
|                 key={snapshot.ID} | ||||
|                 sx={{ justifyContent: "flex-start" }} | ||||
|               > | ||||
|                 {snapshot.Name} | ||||
|               </Button> | ||||
| @@ -280,6 +414,15 @@ export const DevicesTable = observer(() => { | ||||
|             </Typography> | ||||
|           )} | ||||
|         </div> | ||||
|         <Button | ||||
|           onClick={toggleSendSnapshotModal} | ||||
|           color="inherit" | ||||
|           variant="outlined" | ||||
|           sx={{ mt: 3 }} | ||||
|           fullWidth | ||||
|         > | ||||
|           Отмена | ||||
|         </Button> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -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 ( | ||||
|     <Box | ||||
| @@ -22,6 +25,7 @@ export function MediaViewer({ media }: Readonly<{ media?: MediaData }>) { | ||||
|         justifyContent: "center", | ||||
|         margin: "0 auto", | ||||
|       }} | ||||
|       className={className} | ||||
|     > | ||||
|       {media?.media_type === 1 && ( | ||||
|         <img | ||||
|   | ||||
		Reference in New Issue
	
	Block a user