Compare commits
	
		
			33 Commits
		
	
	
		
			d8302e05b4
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 | |||
| a357994025 | |||
| 7382a85082 | |||
| db64beb3ee | |||
|  | 1abd6b30a4 | ||
|  | b25df42960 | ||
| 34ba3c1db0 | |||
| 4f038551a2 | |||
| 470a58a3fa | |||
| 89d7fc2748 | |||
| 97f95fc394 | |||
| bf117ef048 | |||
| ced3067915 | |||
| a908c63771 | |||
| 06eafee3f4 | |||
| 717031cd7a | |||
| 2d4a1e169b | |||
| e2547cb571 | |||
| 78800ee2ae | |||
| d415441af8 | |||
| 32a7cb44d1 | |||
| 481385c2f4 | |||
| 2117a6836e | |||
| f49caf3ec8 | |||
| 300ff262ce | |||
| 27cb644242 | 
							
								
								
									
										1
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								.env
									
									
									
									
									
								
							| @@ -1,2 +1,3 @@ | ||||
| VITE_API_URL='https://wn.krbl.ru' | ||||
| VITE_REACT_APP ='https://wn.krbl.ru/' | ||||
| VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' | ||||
							
								
								
									
										32
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| # Stage 1: Build the application | ||||
| FROM node:20-alpine AS build | ||||
|  | ||||
| # Set working directory | ||||
| WORKDIR /app | ||||
|  | ||||
| # Copy package.json and yarn.lock | ||||
| COPY package.json yarn.lock ./ | ||||
|  | ||||
| # Install dependencies | ||||
| RUN yarn install --frozen-lockfile | ||||
|  | ||||
| # Copy the rest of the application code | ||||
| COPY . . | ||||
|  | ||||
| # Build the application | ||||
| RUN yarn build | ||||
|  | ||||
| # Stage 2: Serve the application with Nginx | ||||
| FROM nginx:alpine | ||||
|  | ||||
| # Copy the built application from the build stage | ||||
| COPY --from=build /app/dist /usr/share/nginx/html | ||||
|  | ||||
| # Copy nginx configuration (optional, can be added later if needed) | ||||
| # COPY nginx.conf /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| # Expose port 80 | ||||
| EXPOSE 80 | ||||
|  | ||||
| # Start Nginx server | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
							
								
								
									
										38
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| # Variables | ||||
| IMAGE_NAME = white-nights-admin-panel | ||||
| IMAGE_TAG = latest | ||||
| FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG) | ||||
| ARCHIVE_NAME = white-nights-admin-panel-image.zip | ||||
|  | ||||
| # Default target | ||||
| .PHONY: help | ||||
| help: | ||||
| 	@echo "Available commands:" | ||||
| 	@echo "  make build-image    - Build Docker image" | ||||
| 	@echo "  make export-image   - Build Docker image and export it to a zip archive" | ||||
| 	@echo "  make clean          - Remove Docker image and zip archive" | ||||
| 	@echo "  make help           - Show this help message" | ||||
|  | ||||
| # Build Docker image | ||||
| .PHONY: build-image | ||||
| build-image: | ||||
| 	@echo "Building Docker image: $(FULL_IMAGE_NAME)" | ||||
| 	docker build -t $(FULL_IMAGE_NAME) . | ||||
|  | ||||
| # Export Docker image to zip archive | ||||
| .PHONY: export-image | ||||
| export-image: build-image | ||||
| 	@echo "Exporting Docker image to $(ARCHIVE_NAME)" | ||||
| 	docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME) | ||||
| 	@echo "Image exported successfully to $(ARCHIVE_NAME)" | ||||
|  | ||||
| # Clean up | ||||
| .PHONY: clean | ||||
| clean: | ||||
| 	@echo "Removing Docker image and zip archive" | ||||
| 	-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true | ||||
| 	-rm -f $(ARCHIVE_NAME) 2>/dev/null || true | ||||
| 	@echo "Clean up completed" | ||||
|  | ||||
| # Default target when no arguments provided | ||||
| .DEFAULT_GOAL := help | ||||
							
								
								
									
										109
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										109
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -15,6 +15,7 @@ | ||||
|         "@mui/material": "^7.1.0", | ||||
|         "@mui/x-data-grid": "^8.5.1", | ||||
|         "@photo-sphere-viewer/core": "^5.13.2", | ||||
|         "@pixi/react": "^8.0.2", | ||||
|         "@react-three/drei": "^10.1.2", | ||||
|         "@react-three/fiber": "^9.1.2", | ||||
|         "@tailwindcss/vite": "^4.1.8", | ||||
| @@ -25,6 +26,7 @@ | ||||
|         "mobx-react-lite": "^4.1.0", | ||||
|         "ol": "^10.5.0", | ||||
|         "path": "^0.12.7", | ||||
|         "pixi.js": "^8.10.1", | ||||
|         "react": "^19.1.0", | ||||
|         "react-colorful": "^5.6.1", | ||||
|         "react-dom": "^19.1.0", | ||||
| @@ -1201,6 +1203,29 @@ | ||||
|       "integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@pixi/colord": { | ||||
|       "version": "2.9.6", | ||||
|       "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", | ||||
|       "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@pixi/react": { | ||||
|       "version": "8.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/@pixi/react/-/react-8.0.2.tgz", | ||||
|       "integrity": "sha512-A42Bw/1YlxxCXhb+nDIgzPpACx5dOh7Yi+ZfBMlZ1sBB/qc7qyX9k7bdDtrnMqz8OofyF2FXg9gPw9MISFsQTA==", | ||||
|       "license": "MIT", | ||||
|       "workspaces": [ | ||||
|         "docs" | ||||
|       ], | ||||
|       "dependencies": { | ||||
|         "its-fine": "^2.0.0", | ||||
|         "react-reconciler": "0.31.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "pixi.js": "^8.2.6", | ||||
|         "react": ">=19.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@popperjs/core": { | ||||
|       "version": "2.11.8", | ||||
|       "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", | ||||
| @@ -1476,6 +1501,12 @@ | ||||
|         "@types/tern": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/css-font-loading-module": { | ||||
|       "version": "0.0.12", | ||||
|       "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", | ||||
|       "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/debug": { | ||||
|       "version": "4.1.12", | ||||
|       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", | ||||
| @@ -1491,6 +1522,12 @@ | ||||
|       "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/earcut": { | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", | ||||
|       "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/estree": { | ||||
|       "version": "1.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", | ||||
| @@ -1980,6 +2017,15 @@ | ||||
|       "integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==", | ||||
|       "license": "BSD-3-Clause" | ||||
|     }, | ||||
|     "node_modules/@xmldom/xmldom": { | ||||
|       "version": "0.8.10", | ||||
|       "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", | ||||
|       "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=10.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/acorn": { | ||||
|       "version": "8.14.1", | ||||
|       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", | ||||
| @@ -3281,6 +3327,15 @@ | ||||
|         "node": ">= 0.4" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/gifuct-js": { | ||||
|       "version": "2.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", | ||||
|       "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "js-binary-schema-parser": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/glob-parent": { | ||||
|       "version": "6.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", | ||||
| @@ -3762,6 +3817,12 @@ | ||||
|       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", | ||||
|       "license": "ISC" | ||||
|     }, | ||||
|     "node_modules/ismobilejs": { | ||||
|       "version": "1.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", | ||||
|       "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/its-fine": { | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", | ||||
| @@ -3783,6 +3844,12 @@ | ||||
|         "jiti": "lib/jiti-cli.mjs" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/js-binary-schema-parser": { | ||||
|       "version": "2.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", | ||||
|       "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/js-tokens": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", | ||||
| @@ -5005,6 +5072,12 @@ | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/parse-svg-path": { | ||||
|       "version": "0.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", | ||||
|       "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/parse5": { | ||||
|       "version": "7.3.0", | ||||
|       "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", | ||||
| @@ -5091,6 +5164,28 @@ | ||||
|         "url": "https://github.com/sponsors/jonschlinkert" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/pixi.js": { | ||||
|       "version": "8.11.0", | ||||
|       "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz", | ||||
|       "integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@pixi/colord": "^2.9.6", | ||||
|         "@types/css-font-loading-module": "^0.0.12", | ||||
|         "@types/earcut": "^3.0.0", | ||||
|         "@webgpu/types": "^0.1.40", | ||||
|         "@xmldom/xmldom": "^0.8.10", | ||||
|         "earcut": "^3.0.1", | ||||
|         "eventemitter3": "^5.0.1", | ||||
|         "gifuct-js": "^2.1.2", | ||||
|         "ismobilejs": "^1.1.1", | ||||
|         "parse-svg-path": "^0.1.2" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/pixijs" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/postcss": { | ||||
|       "version": "8.5.3", | ||||
|       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", | ||||
| @@ -6497,20 +6592,6 @@ | ||||
|         "node": ">=18" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/yaml": { | ||||
|       "version": "2.8.0", | ||||
|       "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", | ||||
|       "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", | ||||
|       "license": "ISC", | ||||
|       "optional": true, | ||||
|       "peer": true, | ||||
|       "bin": { | ||||
|         "yaml": "bin.mjs" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">= 14.6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/yocto-queue": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", | ||||
|   | ||||
| @@ -56,5 +56,6 @@ | ||||
|     "typescript": "~5.8.3", | ||||
|     "typescript-eslint": "^8.30.1", | ||||
|     "vite": "^6.3.5" | ||||
|   } | ||||
|   }, | ||||
|   "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" | ||||
| } | ||||
|   | ||||
| @@ -1 +0,0 @@ | ||||
|  | ||||
							
								
								
									
										158
									
								
								src/app/GlobalErrorBoundary.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								src/app/GlobalErrorBoundary.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | ||||
| import React, { Component, ReactNode } from "react"; | ||||
| import { Box, Button, Typography, Paper, Container } from "@mui/material"; | ||||
| import { RefreshCw, Home, AlertTriangle } from "lucide-react"; | ||||
|  | ||||
| interface Props { | ||||
|   children: ReactNode; | ||||
| } | ||||
|  | ||||
| interface State { | ||||
|   hasError: boolean; | ||||
|   error: Error | null; | ||||
| } | ||||
|  | ||||
| export class GlobalErrorBoundary extends Component<Props, State> { | ||||
|   constructor(props: Props) { | ||||
|     super(props); | ||||
|     this.state = { | ||||
|       hasError: false, | ||||
|       error: null, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   static getDerivedStateFromError(error: Error): State { | ||||
|     return { | ||||
|       hasError: true, | ||||
|       error, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { | ||||
|     console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", { | ||||
|       error: error.message, | ||||
|       stack: error.stack, | ||||
|       componentStack: errorInfo.componentStack, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   handleReset = () => { | ||||
|     this.setState({ | ||||
|       hasError: false, | ||||
|       error: null, | ||||
|     }); | ||||
|     window.location.reload(); | ||||
|   }; | ||||
|  | ||||
|   handleGoHome = () => { | ||||
|     this.setState({ | ||||
|       hasError: false, | ||||
|       error: null, | ||||
|     }); | ||||
|     window.location.href = "/"; | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     if (this.state.hasError) { | ||||
|       return ( | ||||
|         <Box | ||||
|           sx={{ | ||||
|             minHeight: "100vh", | ||||
|             display: "flex", | ||||
|             justifyContent: "center", | ||||
|             alignItems: "center", | ||||
|             backgroundColor: "background.default", | ||||
|             p: 2, | ||||
|           }} | ||||
|         > | ||||
|           <Container maxWidth="sm"> | ||||
|             <Paper | ||||
|               elevation={3} | ||||
|               sx={{ | ||||
|                 p: 4, | ||||
|                 textAlign: "center", | ||||
|               }} | ||||
|             > | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   display: "flex", | ||||
|                   justifyContent: "center", | ||||
|                   mb: 3, | ||||
|                 }} | ||||
|               > | ||||
|                 <AlertTriangle size={64} color="#f44336" /> | ||||
|               </Box> | ||||
|  | ||||
|               <Typography variant="h4" component="h1" gutterBottom> | ||||
|                 Упс! Что-то пошло не так | ||||
|               </Typography> | ||||
|  | ||||
|               <Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}> | ||||
|                 Приложение столкнулось с неожиданной ошибкой. Попробуйте | ||||
|                 перезагрузить страницу или вернуться на главную. | ||||
|               </Typography> | ||||
|  | ||||
|               {this.state.error?.message && ( | ||||
|                 <Paper | ||||
|                   variant="outlined" | ||||
|                   sx={{ | ||||
|                     p: 2, | ||||
|                     mb: 3, | ||||
|                     backgroundColor: "error.light", | ||||
|                     color: "error.contrastText", | ||||
|                     textAlign: "left", | ||||
|                   }} | ||||
|                 > | ||||
|                   <Typography | ||||
|                     variant="caption" | ||||
|                     sx={{ fontWeight: "bold", display: "block", mb: 1 }} | ||||
|                   > | ||||
|                     Информация об ошибке: | ||||
|                   </Typography> | ||||
|                   <Typography | ||||
|                     variant="caption" | ||||
|                     sx={{ | ||||
|                       fontFamily: "monospace", | ||||
|                       fontSize: "0.75rem", | ||||
|                       wordBreak: "break-word", | ||||
|                       display: "block", | ||||
|                     }} | ||||
|                   > | ||||
|                     {this.state.error.message} | ||||
|                   </Typography> | ||||
|                 </Paper> | ||||
|               )} | ||||
|  | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   display: "flex", | ||||
|                   gap: 2, | ||||
|                   justifyContent: "center", | ||||
|                   flexWrap: "wrap", | ||||
|                 }} | ||||
|               > | ||||
|                 <Button | ||||
|                   variant="outlined" | ||||
|                   startIcon={<Home size={16} />} | ||||
|                   onClick={this.handleGoHome} | ||||
|                   size="large" | ||||
|                 > | ||||
|                   На главную | ||||
|                 </Button> | ||||
|                 <Button | ||||
|                   variant="contained" | ||||
|                   startIcon={<RefreshCw size={16} />} | ||||
|                   onClick={this.handleReset} | ||||
|                   size="large" | ||||
|                 > | ||||
|                   Перезагрузить | ||||
|                 </Button> | ||||
|               </Box> | ||||
|             </Paper> | ||||
|           </Container> | ||||
|         </Box> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return this.props.children; | ||||
|   } | ||||
| } | ||||
| @@ -4,10 +4,13 @@ import { Router } from "./router"; | ||||
| import { CustomTheme } from "@shared"; | ||||
| import { ThemeProvider } from "@mui/material/styles"; | ||||
| import { ToastContainer } from "react-toastify"; | ||||
| import { GlobalErrorBoundary } from "./GlobalErrorBoundary"; | ||||
|  | ||||
| export const App: React.FC = () => ( | ||||
|   <ThemeProvider theme={CustomTheme.Light}> | ||||
|     <ToastContainer /> | ||||
|     <Router /> | ||||
|   </ThemeProvider> | ||||
|   <GlobalErrorBoundary> | ||||
|     <ThemeProvider theme={CustomTheme.Light}> | ||||
|       <ToastContainer /> | ||||
|       <Router /> | ||||
|     </ThemeProvider> | ||||
|   </GlobalErrorBoundary> | ||||
| ); | ||||
|   | ||||
| @@ -16,12 +16,12 @@ import { | ||||
|   SnapshotListPage, | ||||
|   CarrierListPage, | ||||
|   StationListPage, | ||||
|   VehicleListPage, | ||||
|   // VehicleListPage, | ||||
|   ArticleListPage, | ||||
|   CityPreviewPage, | ||||
|   CountryPreviewPage, | ||||
|   VehiclePreviewPage, | ||||
|   CarrierPreviewPage, | ||||
|  | ||||
|   // CountryPreviewPage, | ||||
|   // VehiclePreviewPage, | ||||
|   // CarrierPreviewPage, | ||||
|   SnapshotCreatePage, | ||||
|   CountryCreatePage, | ||||
|   CityCreatePage, | ||||
| @@ -31,7 +31,7 @@ import { | ||||
|   CityEditPage, | ||||
|   UserCreatePage, | ||||
|   UserEditPage, | ||||
|   VehicleEditPage, | ||||
|   // VehicleEditPage, | ||||
|   CarrierEditPage, | ||||
|   StationCreatePage, | ||||
|   StationPreviewPage, | ||||
| @@ -39,6 +39,8 @@ import { | ||||
|   RouteCreatePage, | ||||
|   RoutePreview, | ||||
|   RouteEditPage, | ||||
|   ArticlePreviewPage, | ||||
|   CountryAddPage, | ||||
| } from "@pages"; | ||||
| import { authStore, createSightStore, editSightStore } from "@shared"; | ||||
| import { Layout } from "@widgets"; | ||||
| @@ -56,7 +58,7 @@ import { | ||||
| const PublicRoute = ({ children }: { children: React.ReactNode }) => { | ||||
|   const { isAuthenticated } = authStore; | ||||
|   if (isAuthenticated) { | ||||
|     return <Navigate to="/sight" replace />; | ||||
|     return <Navigate to="/map" replace />; | ||||
|   } | ||||
|   return <>{children}</>; | ||||
| }; | ||||
| @@ -68,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { | ||||
|     return <Navigate to="/login" replace />; | ||||
|   } | ||||
|   if (location.pathname === "/") { | ||||
|     return <Navigate to="/sight" replace />; | ||||
|     return <Navigate to="/map" replace />; | ||||
|   } | ||||
|   return <>{children}</>; | ||||
| }; | ||||
| @@ -133,12 +135,13 @@ const router = createBrowserRouter([ | ||||
|       // Country | ||||
|       { path: "country", element: <CountryListPage /> }, | ||||
|       { path: "country/create", element: <CountryCreatePage /> }, | ||||
|       { path: "country/:id", element: <CountryPreviewPage /> }, | ||||
|       { path: "country/add", element: <CountryAddPage /> }, | ||||
|       // { path: "country/:id", element: <CountryPreviewPage /> }, | ||||
|       { path: "country/:id/edit", element: <CountryEditPage /> }, | ||||
|       // City | ||||
|       { path: "city", element: <CityListPage /> }, | ||||
|       { path: "city/create", element: <CityCreatePage /> }, | ||||
|       { path: "city/:id", element: <CityPreviewPage /> }, | ||||
|       // { path: "city/:id", element: <CityPreviewPage /> }, | ||||
|       { path: "city/:id/edit", element: <CityEditPage /> }, | ||||
|       // Route | ||||
|       { path: "route", element: <RouteListPage /> }, | ||||
| @@ -156,7 +159,7 @@ const router = createBrowserRouter([ | ||||
|       // Carrier | ||||
|       { path: "carrier", element: <CarrierListPage /> }, | ||||
|       { path: "carrier/create", element: <CarrierCreatePage /> }, | ||||
|       { path: "carrier/:id", element: <CarrierPreviewPage /> }, | ||||
|       // { path: "carrier/:id", element: <CarrierPreviewPage /> }, | ||||
|       { path: "carrier/:id/edit", element: <CarrierEditPage /> }, | ||||
|       // Station | ||||
|       { path: "station", element: <StationListPage /> }, | ||||
| @@ -164,13 +167,13 @@ const router = createBrowserRouter([ | ||||
|       { path: "station/:id", element: <StationPreviewPage /> }, | ||||
|       { path: "station/:id/edit", element: <StationEditPage /> }, | ||||
|       // Vehicle | ||||
|       { path: "vehicle", element: <VehicleListPage /> }, | ||||
|       // { path: "vehicle", element: <VehicleListPage /> }, | ||||
|       { path: "vehicle/create", element: <VehicleCreatePage /> }, | ||||
|       { path: "vehicle/:id", element: <VehiclePreviewPage /> }, | ||||
|       { path: "vehicle/:id/edit", element: <VehicleEditPage /> }, | ||||
|       // { path: "vehicle/:id", element: <VehiclePreviewPage /> }, | ||||
|       // { path: "vehicle/:id/edit", element: <VehicleEditPage /> }, | ||||
|       // Article | ||||
|       { path: "article", element: <ArticleListPage /> }, | ||||
|       // { path: "article/:id", element: <ArticlePreviewPage /> }, | ||||
|       { path: "article/:id", element: <ArticlePreviewPage /> }, | ||||
|       // { path: "media/create", element: <CreateMediaPage /> }, | ||||
|     ], | ||||
|   }, | ||||
|   | ||||
| @@ -5,6 +5,7 @@ export interface NavigationItem { | ||||
|   label: string; | ||||
|   icon: LucideIcon; | ||||
|   path?: string; | ||||
|   for_admin?: boolean; | ||||
|   onClick?: () => void; | ||||
|   nestedItems?: NavigationItem[]; | ||||
| } | ||||
|   | ||||
| @@ -9,12 +9,15 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
| import type { NavigationItem } from "../model"; | ||||
| import { useNavigate, useLocation } from "react-router-dom"; | ||||
| import { Plus } from "lucide-react"; | ||||
| import { authStore } from "@shared"; | ||||
|  | ||||
| interface NavigationItemProps { | ||||
|   item: NavigationItem; | ||||
|   open: boolean; | ||||
|   onClick?: () => void; | ||||
|   isNested?: boolean; | ||||
|   onDrawerOpen?: () => void; | ||||
| } | ||||
|  | ||||
| export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
| @@ -22,15 +25,31 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|   open, | ||||
|   onClick, | ||||
|   isNested = false, | ||||
|   onDrawerOpen, | ||||
| }) => { | ||||
|   const Icon = item.icon; | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|   const [isExpanded, setIsExpanded] = React.useState(false); | ||||
|   const { payload } = authStore; | ||||
|  | ||||
|   // @ts-ignore | ||||
|   const isAdmin = payload?.is_admin || false; | ||||
|  | ||||
|   const isActive = item.path ? location.pathname.startsWith(item.path) : false; | ||||
|  | ||||
|   const filteredNestedItems = item.nestedItems?.filter((nestedItem) => { | ||||
|     if (nestedItem.for_admin) { | ||||
|       return isAdmin; | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|   }); | ||||
|  | ||||
|   const handleClick = () => { | ||||
|     if (item.id === "all" && !open) { | ||||
|       onDrawerOpen?.(); | ||||
|     } | ||||
|     if (item.nestedItems) { | ||||
|       setIsExpanded(!isExpanded); | ||||
|     } else if (onClick) { | ||||
| @@ -58,7 +77,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|                   justifyContent: "center", | ||||
|                 }, | ||||
|             isNested && { | ||||
|               pl: 4, | ||||
|               pl: open ? 4 : 2.5, | ||||
|             }, | ||||
|             isActive && { | ||||
|               backgroundColor: "rgba(0, 0, 0, 0.08)", | ||||
| @@ -84,7 +103,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|                   }, | ||||
|             ]} | ||||
|           > | ||||
|             <Icon /> | ||||
|             {Icon ? <Icon /> : <Plus />} | ||||
|           </ListItemIcon> | ||||
|           <ListItemText | ||||
|             primary={item.label} | ||||
| @@ -102,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ | ||||
|               }, | ||||
|             ]} | ||||
|           /> | ||||
|           {item.nestedItems && | ||||
|           {filteredNestedItems && | ||||
|             filteredNestedItems.length > 0 && | ||||
|             open && | ||||
|             (isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)} | ||||
|         </ListItemButton> | ||||
|       </ListItem> | ||||
|       {item.nestedItems && ( | ||||
|         <Collapse in={isExpanded && open} timeout="auto" unmountOnExit> | ||||
|       {filteredNestedItems && filteredNestedItems.length > 0 && ( | ||||
|         <Collapse in={isExpanded} timeout="auto" unmountOnExit> | ||||
|           <List component="div" disablePadding> | ||||
|             {item.nestedItems.map((nestedItem) => ( | ||||
|             {filteredNestedItems.map((nestedItem) => ( | ||||
|               <NavigationItemComponent | ||||
|                 key={nestedItem.id} | ||||
|                 item={nestedItem} | ||||
|   | ||||
| @@ -1,34 +1,62 @@ | ||||
| import List from "@mui/material/List"; | ||||
| import Divider from "@mui/material/Divider"; | ||||
| import { NAVIGATION_ITEMS } from "@shared"; | ||||
| import { authStore, NAVIGATION_ITEMS } from "@shared"; | ||||
| import { NavigationItem, NavigationItemComponent } from "@entities"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| export const NavigationList = ({ open }: { open: boolean }) => { | ||||
|   const primaryItems = NAVIGATION_ITEMS.primary; | ||||
|   const secondaryItems = NAVIGATION_ITEMS.secondary; | ||||
| interface NavigationListProps { | ||||
|   open: boolean; | ||||
|   onDrawerOpen?: () => void; | ||||
| } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <List> | ||||
|         {primaryItems.map((item) => ( | ||||
|           <NavigationItemComponent | ||||
|             key={item.id} | ||||
|             item={item as NavigationItem} | ||||
|             open={open} | ||||
|           /> | ||||
|         ))} | ||||
|       </List> | ||||
|       <Divider /> | ||||
|       <List> | ||||
|         {secondaryItems.map((item) => ( | ||||
|           <NavigationItemComponent | ||||
|             key={item.id} | ||||
|             item={item as NavigationItem} | ||||
|             open={open} | ||||
|             onClick={item.onClick ? item.onClick : undefined} | ||||
|           /> | ||||
|         ))} | ||||
|       </List> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| export const NavigationList = observer( | ||||
|   ({ open, onDrawerOpen }: NavigationListProps) => { | ||||
|     const { payload } = authStore; | ||||
|     // @ts-ignore | ||||
|     const isAdmin = Boolean(payload?.is_admin) || false; | ||||
|  | ||||
|     const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => { | ||||
|       if (item.for_admin) { | ||||
|         return isAdmin; | ||||
|       } | ||||
|  | ||||
|       if (item.nestedItems && item.nestedItems.length > 0) { | ||||
|         return item.nestedItems.some((nestedItem) => { | ||||
|           if (nestedItem.for_admin) { | ||||
|             return isAdmin; | ||||
|           } | ||||
|           return true; | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|       <> | ||||
|         <List> | ||||
|           {primaryItems.map((item) => ( | ||||
|             <NavigationItemComponent | ||||
|               key={item.id} | ||||
|               item={item as NavigationItem} | ||||
|               open={open} | ||||
|               onDrawerOpen={onDrawerOpen} | ||||
|             /> | ||||
|           ))} | ||||
|         </List> | ||||
|         <Divider /> | ||||
|         <List> | ||||
|           {NAVIGATION_ITEMS.secondary.map((item) => ( | ||||
|             <NavigationItemComponent | ||||
|               key={item.id} | ||||
|               item={item as NavigationItem} | ||||
|               open={open} | ||||
|               onClick={item.onClick ? item.onClick : undefined} | ||||
|               onDrawerOpen={onDrawerOpen} | ||||
|             /> | ||||
|           ))} | ||||
|         </List> | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|   | ||||
							
								
								
									
										33
									
								
								src/pages/Article/ArticleCreatePage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/pages/Article/ArticleCreatePage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import React from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { articlesStore } from "@shared"; | ||||
|  | ||||
| const ArticleCreatePage: React.FC = () => { | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const { articleData } = articlesStore; | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-10 w-full items-end"> | ||||
|       <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|         <h1 className="text-3xl break-words"> | ||||
|           {articleData?.ru?.heading || "Создание статьи"} | ||||
|         </h1> | ||||
|       </div> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default ArticleCreatePage; | ||||
							
								
								
									
										49
									
								
								src/pages/Article/ArticleEditPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/pages/Article/ArticleEditPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import React, { useEffect } from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| const ArticleEditPage: React.FC = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const { id } = useParams(); | ||||
|  | ||||
|   const { articleData, getArticle } = articlesStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (id) { | ||||
|       // Fetch data for all languages | ||||
|       getArticle(parseInt(id), "ru"); | ||||
|       getArticle(parseInt(id), "en"); | ||||
|       getArticle(parseInt(id), "zh"); | ||||
|     } | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-10 w-full items-end"> | ||||
|       <div className="flex gap-10 items-center mb-5 max-w-[80%]"> | ||||
|         <h1 className="text-3xl break-words"> | ||||
|           {articleData?.ru?.heading || "Редактирование статьи"} | ||||
|         </h1> | ||||
|       </div> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| export default ArticleEditPage; | ||||
| @@ -1,20 +1,30 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Trash2, Eye } from "lucide-react"; | ||||
| import { Trash2, Eye, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const ArticleListPage = observer(() => { | ||||
|   const { articleList, getArticleList } = articlesStore; | ||||
|   const { articleList, getArticleList, deleteArticles } = articlesStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const { language } = languageStore; | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getArticleList(); | ||||
|     const fetchArticles = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getArticleList(); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchArticles(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -22,11 +32,21 @@ export const ArticleListPage = observer(() => { | ||||
|       field: "heading", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return params.value ? ( | ||||
|           params.value | ||||
|         ) : ( | ||||
|           <div className="flex h-full gap-7 items-center"> | ||||
|             <Minus size={20} className="text-red-500" /> | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "Действия", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -59,18 +79,53 @@ export const ArticleListPage = observer(() => { | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div className="w-full"> | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|         /> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Статьи</h1> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <div className="w-full"> | ||||
|           <DataGrid | ||||
|             rows={rows} | ||||
|             columns={columns} | ||||
|             hideFooterPagination | ||||
|             checkboxSelection | ||||
|             loading={isLoading} | ||||
|             localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|             onRowSelectionModelChange={(newSelection) => { | ||||
|               setIds(Array.from(newSelection.ids) as number[]); | ||||
|             }} | ||||
|             hideFooter | ||||
|             slots={{ | ||||
|               noRowsOverlay: () => ( | ||||
|                 <Box | ||||
|                   sx={{ mt: 5, textAlign: "center", color: "text.secondary" }} | ||||
|                 > | ||||
|                   {isLoading ? <CircularProgress size={20} /> : "Нет статей"} | ||||
|                 </Box> | ||||
|               ), | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           if (rowId) { | ||||
|             await deleteArticles([parseInt(rowId)]); | ||||
|             getArticleList(); | ||||
|           } | ||||
|           setIsDeleteModalOpen(false); | ||||
| @@ -81,6 +136,19 @@ export const ArticleListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await deleteArticles(ids); | ||||
|           getArticleList(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										85
									
								
								src/pages/Article/ArticlePreviewPage/PreviewLeftWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/pages/Article/ArticlePreviewPage/PreviewLeftWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| import { Paper, Box, Typography } from "@mui/material"; | ||||
| import { MediaViewer, ReactMarkdownComponent } from "@widgets"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| export const PreviewLeftWidget = observer(() => { | ||||
|   const { articleMedia, articleData } = articlesStore; | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   return ( | ||||
|     <Paper | ||||
|       elevation={3} | ||||
|       sx={{ | ||||
|         width: "100%", | ||||
|         minWidth: 320, | ||||
|         background: | ||||
|           "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", | ||||
|         overflowY: "auto", | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|         borderRadius: "10px", | ||||
|       }} | ||||
|     > | ||||
|       <Box | ||||
|         sx={{ | ||||
|           overflow: "hidden", | ||||
|           width: "100%", | ||||
|           minHeight: 100, | ||||
|           padding: "3px", | ||||
|           display: "flex", | ||||
|           alignItems: "center", | ||||
|           justifyContent: "center", | ||||
|           "& img": { | ||||
|             borderTopLeftRadius: "10px", | ||||
|             borderTopRightRadius: "10px", | ||||
|             width: "100%", | ||||
|             height: "auto", | ||||
|             objectFit: "contain", | ||||
|           }, | ||||
|         }} | ||||
|       > | ||||
|         {articleMedia && <MediaViewer media={articleMedia} fullWidth />} | ||||
|       </Box> | ||||
|       <Box | ||||
|         sx={{ | ||||
|           background: | ||||
|             "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", | ||||
|           color: "white", | ||||
|           margin: "5px 0px 5px 0px", | ||||
|           display: "flex", | ||||
|           flexDirection: "column", | ||||
|           gap: 1, | ||||
|           padding: 1, | ||||
|         }} | ||||
|       > | ||||
|         <Typography | ||||
|           variant="h5" | ||||
|           component="h2" | ||||
|           sx={{ | ||||
|             wordBreak: "break-word", | ||||
|             fontSize: "24px", | ||||
|             fontWeight: 700, | ||||
|             lineHeight: "120%", | ||||
|           }} | ||||
|         > | ||||
|           {articleData?.[language]?.heading || "Название информации"} | ||||
|         </Typography> | ||||
|       </Box> | ||||
|       {articleData?.[language]?.body && ( | ||||
|         <Box | ||||
|           sx={{ | ||||
|             padding: 1, | ||||
|             maxHeight: "300px", | ||||
|             overflowY: "scroll", | ||||
|             background: | ||||
|               "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", | ||||
|             flexGrow: 1, | ||||
|           }} | ||||
|         > | ||||
|           <ReactMarkdownComponent value={articleData?.[language]?.body} /> | ||||
|         </Box> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
							
								
								
									
										139
									
								
								src/pages/Article/ArticlePreviewPage/PreviewRightWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/pages/Article/ArticlePreviewPage/PreviewRightWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| import { Paper, Box, Typography } from "@mui/material"; | ||||
| import { MediaViewer, ReactMarkdownComponent } from "@widgets"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ImagePlus } from "lucide-react"; | ||||
|  | ||||
| export const PreviewRightWidget = observer(() => { | ||||
|   const { articleData, articleMedia } = articlesStore; | ||||
|   const { language } = languageStore; | ||||
|   const article = articleData?.[language]; | ||||
|   if (!article) return null; | ||||
|  | ||||
|   return ( | ||||
|     <Paper | ||||
|       className="flex-1 flex flex-col max-w-[500px]" | ||||
|       sx={{ | ||||
|         borderRadius: "10px", | ||||
|         overflow: "hidden", | ||||
|       }} | ||||
|       elevation={2} | ||||
|     > | ||||
|       <Box | ||||
|         className="overflow-hidden" | ||||
|         sx={{ | ||||
|           width: "100%", | ||||
|           background: "#877361", | ||||
|           borderColor: "grey.300", | ||||
|           display: "flex", | ||||
|           flexDirection: "column", | ||||
|         }} | ||||
|       > | ||||
|         {articleMedia ? ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               overflow: "hidden", | ||||
|               width: "100%", | ||||
|               padding: "2px 2px 0px 2px", | ||||
|               "& img": { | ||||
|                 borderTopLeftRadius: "10px", | ||||
|                 borderTopRightRadius: "10px", | ||||
|                 width: "100%", | ||||
|                 height: "auto", | ||||
|                 objectFit: "contain", | ||||
|               }, | ||||
|             }} | ||||
|           > | ||||
|             <MediaViewer media={articleMedia} fullWidth fullHeight /> | ||||
|           </Box> | ||||
|         ) : ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               width: "100%", | ||||
|               height: 200, | ||||
|               flexShrink: 0, | ||||
|               backgroundColor: "rgba(0,0,0,0.1)", | ||||
|               display: "flex", | ||||
|               alignItems: "center", | ||||
|               justifyContent: "center", | ||||
|             }} | ||||
|           > | ||||
|             <ImagePlus size={48} color="white" /> | ||||
|           </Box> | ||||
|         )} | ||||
|  | ||||
|         <Box | ||||
|           sx={{ | ||||
|             p: 1, | ||||
|             wordBreak: "break-word", | ||||
|             fontSize: "24px", | ||||
|             fontWeight: 700, | ||||
|             lineHeight: "120%", | ||||
|             backdropFilter: "blur(12px)", | ||||
|             borderBottom: "1px solid #A89F90", | ||||
|             boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)", | ||||
|             background: | ||||
|               "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="h6" color="white"> | ||||
|             {article.heading || "Выберите статью"} | ||||
|           </Typography> | ||||
|         </Box> | ||||
|  | ||||
|         <Box | ||||
|           sx={{ | ||||
|             padding: 1, | ||||
|             minHeight: "200px", | ||||
|             maxHeight: "300px", | ||||
|             overflowY: "scroll", | ||||
|             background: | ||||
|               "rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)", | ||||
|             flexGrow: 1, | ||||
|           }} | ||||
|         > | ||||
|           {article.body ? ( | ||||
|             <ReactMarkdownComponent value={article.body} /> | ||||
|           ) : ( | ||||
|             <Typography | ||||
|               color="rgba(255,255,255,0.7)" | ||||
|               sx={{ textAlign: "center", mt: 4 }} | ||||
|             > | ||||
|               Предпросмотр статьи появится здесь | ||||
|             </Typography> | ||||
|           )} | ||||
|         </Box> | ||||
|  | ||||
|         {/* @ts-ignore */} | ||||
|         {articleData?.right && articleData?.right.length > 1 && ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               p: 2, | ||||
|               display: "flex", | ||||
|               justifyContent: "space-between", | ||||
|               fontSize: "24px", | ||||
|               fontWeight: 700, | ||||
|               lineHeight: "120%", | ||||
|               flexWrap: "wrap", | ||||
|               gap: 1, | ||||
|               backdropFilter: "blur(12px)", | ||||
|               boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)", | ||||
|               background: | ||||
|                 "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", | ||||
|             }} | ||||
|           > | ||||
|             {/* @ts-ignore */} | ||||
|             {articleData.right.map((a, idx) => ( | ||||
|               <button | ||||
|                 key={idx} | ||||
|                 className="inline-block text-left text-xs text-white" | ||||
|               > | ||||
|                 {a.heading} | ||||
|               </button> | ||||
|             ))} | ||||
|           </Box> | ||||
|         )} | ||||
|       </Box> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
							
								
								
									
										57
									
								
								src/pages/Article/ArticlePreviewPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/pages/Article/ArticlePreviewPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { useEffect } from "react"; | ||||
| import { Box } from "@mui/material"; | ||||
| import { PreviewLeftWidget } from "./PreviewLeftWidget"; | ||||
| import { PreviewRightWidget } from "./PreviewRightWidget"; | ||||
| import { articlesStore, languageStore } from "@shared"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
|  | ||||
| export const ArticlePreviewPage = () => { | ||||
|   const navigate = useNavigate(); | ||||
|   const { id } = useParams(); | ||||
|   const { getArticle, getArticleMedia, getArticlePreview } = articlesStore; | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       if (id) { | ||||
|         await getArticle(Number(id), language); | ||||
|         await getArticleMedia(Number(id)); | ||||
|         await getArticlePreview(Number(id)); | ||||
|       } | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [id, language]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="flex items-center gap-4 mb-10"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|  | ||||
|       <Box | ||||
|         sx={{ | ||||
|           display: "flex", | ||||
|           gap: 2, | ||||
|           p: 2, | ||||
|           justifyContent: "center", | ||||
|           margin: "0 auto", | ||||
|         }} | ||||
|       > | ||||
|         <Box sx={{ width: "320px" }}> | ||||
|           <PreviewLeftWidget /> | ||||
|         </Box> | ||||
|  | ||||
|         <Box sx={{ width: "500px" }}> | ||||
|           <PreviewRightWidget /> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| @@ -1 +1,2 @@ | ||||
| export * from "./ArticleListPage"; | ||||
| export * from "./ArticlePreviewPage"; | ||||
|   | ||||
| @@ -12,41 +12,61 @@ import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { carrierStore, cityStore, mediaStore } from "@shared"; | ||||
| import { | ||||
|   carrierStore, | ||||
|   cityStore, | ||||
|   mediaStore, | ||||
|   languageStore, | ||||
|   useSelectedCity, | ||||
| } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { ImageUploadCard, LanguageSwitcher } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CarrierCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [fullName, setFullName] = useState(""); | ||||
|   const [shortName, setShortName] = useState(""); | ||||
|   const [cityId, setCityId] = useState<number | null>(null); | ||||
|   const [main_color, setMainColor] = useState("#000000"); | ||||
|   const [left_color, setLeftColor] = useState("#ffffff"); | ||||
|   const [right_color, setRightColor] = useState("#ff0000"); | ||||
|   const [slogan, setSlogan] = useState(""); | ||||
|   const { createCarrierData, setCreateCarrierData } = carrierStore; | ||||
|   const { language } = languageStore; | ||||
|   const { selectedCityId } = useSelectedCity(); | ||||
|   const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null | ||||
|   >(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     cityStore.getCities("ru"); | ||||
|     mediaStore.getMedia(); | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   // Автоматически устанавливаем выбранный город при загрузке страницы | ||||
|   useEffect(() => { | ||||
|     if (selectedCityId && !createCarrierData.city_id) { | ||||
|       setCreateCarrierData( | ||||
|         createCarrierData[language].full_name, | ||||
|         createCarrierData[language].short_name, | ||||
|         selectedCityId, | ||||
|         createCarrierData[language].slogan, | ||||
|         selectedMediaId || "", | ||||
|         language | ||||
|       ); | ||||
|     } | ||||
|   }, [selectedCityId, createCarrierData.city_id]); | ||||
|  | ||||
|   const handleCreate = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
|       await carrierStore.createCarrier( | ||||
|         fullName, | ||||
|         shortName, | ||||
|         cityStore.cities.ru.find((c) => c.id === cityId)?.name!, | ||||
|         cityId!, | ||||
|         main_color, | ||||
|         left_color, | ||||
|         right_color, | ||||
|         slogan, | ||||
|         selectedMediaId! | ||||
|       ); | ||||
|       await carrierStore.createCarrier(); | ||||
|  | ||||
|       toast.success("Перевозчик успешно создан"); | ||||
|       navigate("/carrier"); | ||||
|     } catch (error) { | ||||
| @@ -56,8 +76,30 @@ export const CarrierCreatePage = observer(() => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleMediaSelect = (media: { | ||||
|     id: string; | ||||
|     filename: string; | ||||
|     media_name?: string; | ||||
|     media_type: number; | ||||
|   }) => { | ||||
|     setSelectedMediaId(media.id); | ||||
|     setCreateCarrierData( | ||||
|       createCarrierData[language].full_name, | ||||
|       createCarrierData[language].short_name, | ||||
|       createCarrierData.city_id, | ||||
|       createCarrierData[language].slogan, | ||||
|       media.id, | ||||
|       language | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const selectedMedia = selectedMediaId | ||||
|     ? mediaStore.media.find((m) => m.id === selectedMediaId) | ||||
|     : null; | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
| @@ -69,15 +111,28 @@ export const CarrierCreatePage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|           <h1 className="text-3xl break-words">Создание перевозчика</h1> | ||||
|         </div> | ||||
|  | ||||
|         <FormControl fullWidth> | ||||
|           <InputLabel>Город</InputLabel> | ||||
|           <Select | ||||
|             value={cityId || ""} | ||||
|             value={createCarrierData.city_id || ""} | ||||
|             label="Город" | ||||
|             required | ||||
|             onChange={(e) => setCityId(e.target.value as number)} | ||||
|             onChange={(e) => | ||||
|               setCreateCarrierData( | ||||
|                 createCarrierData[language].full_name, | ||||
|                 createCarrierData[language].short_name, | ||||
|                 e.target.value as number, | ||||
|                 createCarrierData[language].slogan, | ||||
|                 selectedMediaId || "", | ||||
|                 language | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             {cityStore.cities.ru.map((city) => ( | ||||
|             {cityStore.cities["ru"].data.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
| @@ -88,100 +143,83 @@ export const CarrierCreatePage = observer(() => { | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Полное название" | ||||
|           value={fullName} | ||||
|           value={createCarrierData[language].full_name} | ||||
|           required | ||||
|           onChange={(e) => setFullName(e.target.value)} | ||||
|           onChange={(e) => | ||||
|             setCreateCarrierData( | ||||
|               e.target.value, | ||||
|               createCarrierData[language].short_name, | ||||
|               createCarrierData.city_id, | ||||
|               createCarrierData[language].slogan, | ||||
|               selectedMediaId || "", | ||||
|               language | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Короткое название" | ||||
|           value={shortName} | ||||
|           value={createCarrierData[language].short_name} | ||||
|           required | ||||
|           onChange={(e) => setShortName(e.target.value)} | ||||
|           onChange={(e) => | ||||
|             setCreateCarrierData( | ||||
|               createCarrierData[language].full_name, | ||||
|               e.target.value, | ||||
|               createCarrierData.city_id, | ||||
|               createCarrierData[language].slogan, | ||||
|               selectedMediaId || "", | ||||
|               language | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <div className="flex gap-4 w-full "> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Основной цвет" | ||||
|             value={main_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => setMainColor(e.target.value)} | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет левого виджета" | ||||
|             value={left_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => setLeftColor(e.target.value)} | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет правого виджета" | ||||
|             value={right_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => setRightColor(e.target.value)} | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Слоган" | ||||
|           value={slogan} | ||||
|           onChange={(e) => setSlogan(e.target.value)} | ||||
|           value={createCarrierData[language].slogan} | ||||
|           onChange={(e) => | ||||
|             setCreateCarrierData( | ||||
|               createCarrierData[language].full_name, | ||||
|               createCarrierData[language].short_name, | ||||
|               createCarrierData.city_id, | ||||
|               e.target.value, | ||||
|               selectedMediaId || "", | ||||
|               language | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <FormControl fullWidth> | ||||
|             <InputLabel>Логотип</InputLabel> | ||||
|             <Select | ||||
|               value={selectedMediaId || ""} | ||||
|               label="Логотип" | ||||
|               required | ||||
|               onChange={(e) => setSelectedMediaId(e.target.value as string)} | ||||
|             > | ||||
|               {mediaStore.media | ||||
|                 .filter((media) => media.media_type === 3) | ||||
|                 .map((media) => ( | ||||
|                   <MenuItem key={media.id} value={media.id}> | ||||
|                     {media.media_name || media.filename} | ||||
|                   </MenuItem> | ||||
|                 ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           {selectedMediaId && ( | ||||
|             <div className="w-32 h-32"> | ||||
|               <MediaViewer media={{ id: selectedMediaId, media_type: 1 }} /> | ||||
|             </div> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Логотип перевозчика" | ||||
|             imageKey="thumbnail" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setSelectedMediaId(null); | ||||
|               setActiveMenuType(null); | ||||
|               setCreateCarrierData( | ||||
|                 createCarrierData[language].full_name, | ||||
|                 createCarrierData[language].short_name, | ||||
|                 createCarrierData.city_id, | ||||
|                 createCarrierData[language].slogan, | ||||
|                 "", | ||||
|                 language | ||||
|               ); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("image"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("image"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -190,7 +228,10 @@ export const CarrierCreatePage = observer(() => { | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleCreate} | ||||
|           disabled={ | ||||
|             isLoading || !fullName || !shortName || !cityId || !selectedMediaId | ||||
|             isLoading || | ||||
|             !createCarrierData[language].full_name || | ||||
|             !createCarrierData[language].short_name || | ||||
|             !createCarrierData.city_id | ||||
|           } | ||||
|         > | ||||
|           {isLoading ? ( | ||||
| @@ -200,6 +241,28 @@ export const CarrierCreatePage = observer(() => { | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       <SelectMediaDialog | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={1} | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         contextObjectName={createCarrierData[language].full_name} | ||||
|         contextType="carrier" | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={activeMenuType} | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -12,35 +12,70 @@ import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { carrierStore, cityStore, mediaStore } from "@shared"; | ||||
| import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
|   UploadMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CarrierEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const { id } = useParams(); | ||||
|   const { carrier, getCarrier, setEditCarrierData, editCarrierData } = | ||||
|     carrierStore; | ||||
|   const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore; | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null | ||||
|   >(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       await getCarrier(Number(id)); | ||||
|       setEditCarrierData( | ||||
|         carrier?.[Number(id)]?.full_name as string, | ||||
|         carrier?.[Number(id)]?.short_name as string, | ||||
|         carrier?.[Number(id)]?.city as string, | ||||
|         carrier?.[Number(id)]?.city_id as number, | ||||
|         carrier?.[Number(id)]?.main_color as string, | ||||
|         carrier?.[Number(id)]?.left_color as string, | ||||
|         carrier?.[Number(id)]?.right_color as string, | ||||
|         carrier?.[Number(id)]?.slogan as string, | ||||
|         carrier?.[Number(id)]?.logo as string | ||||
|       ); | ||||
|       cityStore.getCities("ru"); | ||||
|       await cityStore.getCities("ru"); | ||||
|       await cityStore.getCities("en"); | ||||
|       await cityStore.getCities("zh"); | ||||
|       const carrierData = await getCarrier(Number(id)); | ||||
|  | ||||
|       if (carrierData) { | ||||
|         setEditCarrierData( | ||||
|           carrierData.ru?.full_name || "", | ||||
|           carrierData.ru?.short_name || "", | ||||
|           carrierData.ru?.city_id || 0, | ||||
|           carrierData.ru?.slogan || "", | ||||
|           carrierData.ru?.logo || "", | ||||
|           "ru" | ||||
|         ); | ||||
|         setEditCarrierData( | ||||
|           carrierData.en?.full_name || "", | ||||
|           carrierData.en?.short_name || "", | ||||
|           carrierData.en?.city_id || 0, | ||||
|           carrierData.en?.slogan || "", | ||||
|           carrierData.en?.logo || "", | ||||
|           "en" | ||||
|         ); | ||||
|         setEditCarrierData( | ||||
|           carrierData.zh?.full_name || "", | ||||
|           carrierData.zh?.short_name || "", | ||||
|           carrierData.zh?.city_id || 0, | ||||
|           carrierData.zh?.slogan || "", | ||||
|           carrierData.zh?.logo || "", | ||||
|           "zh" | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       mediaStore.getMedia(); | ||||
|     })(); | ||||
|  | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, [id]); | ||||
|  | ||||
|   const handleEdit = async () => { | ||||
| @@ -56,8 +91,29 @@ export const CarrierEditPage = observer(() => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleMediaSelect = (media: { | ||||
|     id: string; | ||||
|     filename: string; | ||||
|     media_name?: string; | ||||
|     media_type: number; | ||||
|   }) => { | ||||
|     setEditCarrierData( | ||||
|       editCarrierData[language].full_name, | ||||
|       editCarrierData[language].short_name, | ||||
|       editCarrierData.city_id, | ||||
|       editCarrierData[language].slogan, | ||||
|       media.id, | ||||
|       language | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const selectedMedia = editCarrierData.logo | ||||
|     ? mediaStore.media.find((m) => m.id === editCarrierData.logo) | ||||
|     : null; | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
| @@ -68,6 +124,9 @@ export const CarrierEditPage = observer(() => { | ||||
|         </button> | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|         <h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1> | ||||
|       </div> | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <FormControl fullWidth> | ||||
|           <InputLabel>Город</InputLabel> | ||||
| @@ -77,19 +136,16 @@ export const CarrierEditPage = observer(() => { | ||||
|             required | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData[language].full_name, | ||||
|                 editCarrierData[language].short_name, | ||||
|                 Number(e.target.value), | ||||
|                 editCarrierData.main_color, | ||||
|                 editCarrierData.left_color, | ||||
|                 editCarrierData.right_color, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|                 editCarrierData[language].slogan, | ||||
|                 editCarrierData.logo, | ||||
|                 language | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             {cityStore.cities.ru.map((city) => ( | ||||
|             {cityStore.cities["ru"].data?.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
| @@ -100,19 +156,16 @@ export const CarrierEditPage = observer(() => { | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Полное название" | ||||
|           value={editCarrierData.full_name} | ||||
|           value={editCarrierData[language].full_name} | ||||
|           required | ||||
|           onChange={(e) => | ||||
|             setEditCarrierData( | ||||
|               e.target.value, | ||||
|               editCarrierData.short_name, | ||||
|               editCarrierData.city, | ||||
|               editCarrierData[language].short_name, | ||||
|               editCarrierData.city_id, | ||||
|               editCarrierData.main_color, | ||||
|               editCarrierData.left_color, | ||||
|               editCarrierData.right_color, | ||||
|               editCarrierData.slogan, | ||||
|               editCarrierData.logo | ||||
|               editCarrierData[language].slogan, | ||||
|               editCarrierData.logo, | ||||
|               language | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
| @@ -120,166 +173,57 @@ export const CarrierEditPage = observer(() => { | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Короткое название" | ||||
|           value={editCarrierData.short_name} | ||||
|           value={editCarrierData[language].short_name} | ||||
|           required | ||||
|           onChange={(e) => | ||||
|             setEditCarrierData( | ||||
|               editCarrierData.full_name, | ||||
|               editCarrierData[language].full_name, | ||||
|               e.target.value, | ||||
|               editCarrierData.city, | ||||
|               editCarrierData.city_id, | ||||
|               editCarrierData.main_color, | ||||
|               editCarrierData.left_color, | ||||
|               editCarrierData.right_color, | ||||
|               editCarrierData.slogan, | ||||
|               editCarrierData.logo | ||||
|               editCarrierData[language].slogan, | ||||
|               editCarrierData.logo, | ||||
|               language | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <div className="flex gap-4 w-full"> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Основной цвет" | ||||
|             value={editCarrierData.main_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData.city_id, | ||||
|                 e.target.value, | ||||
|                 editCarrierData.left_color, | ||||
|                 editCarrierData.right_color, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет левого виджета" | ||||
|             value={editCarrierData.left_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData.city_id, | ||||
|                 editCarrierData.main_color, | ||||
|                 e.target.value, | ||||
|                 editCarrierData.right_color, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             label="Цвет правого виджета" | ||||
|             value={editCarrierData.right_color} | ||||
|             className="flex-1 w-full" | ||||
|             onChange={(e) => | ||||
|               setEditCarrierData( | ||||
|                 editCarrierData.full_name, | ||||
|                 editCarrierData.short_name, | ||||
|                 editCarrierData.city, | ||||
|                 editCarrierData.city_id, | ||||
|                 editCarrierData.main_color, | ||||
|                 editCarrierData.left_color, | ||||
|                 e.target.value, | ||||
|                 editCarrierData.slogan, | ||||
|                 editCarrierData.logo | ||||
|               ) | ||||
|             } | ||||
|             type="color" | ||||
|             sx={{ | ||||
|               "& input": { | ||||
|                 height: "50px", | ||||
|                 paddingBlock: "14px", | ||||
|                 paddingInline: "14px", | ||||
|                 cursor: "pointer", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Слоган" | ||||
|           value={editCarrierData.slogan} | ||||
|           value={editCarrierData[language].slogan} | ||||
|           onChange={(e) => | ||||
|             setEditCarrierData( | ||||
|               editCarrierData.full_name, | ||||
|               editCarrierData.short_name, | ||||
|               editCarrierData.city, | ||||
|               editCarrierData[language].full_name, | ||||
|               editCarrierData[language].short_name, | ||||
|               editCarrierData.city_id, | ||||
|               editCarrierData.main_color, | ||||
|               editCarrierData.left_color, | ||||
|               editCarrierData.right_color, | ||||
|               e.target.value, | ||||
|               editCarrierData.logo | ||||
|               editCarrierData.logo, | ||||
|               language | ||||
|             ) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <FormControl fullWidth> | ||||
|             <InputLabel>Логотип</InputLabel> | ||||
|             <Select | ||||
|               value={editCarrierData.logo || ""} | ||||
|               label="Логотип" | ||||
|               required | ||||
|               onChange={(e) => | ||||
|                 setEditCarrierData( | ||||
|                   editCarrierData.full_name, | ||||
|                   editCarrierData.short_name, | ||||
|                   editCarrierData.city, | ||||
|                   editCarrierData.city_id, | ||||
|                   editCarrierData.main_color, | ||||
|                   editCarrierData.left_color, | ||||
|                   editCarrierData.right_color, | ||||
|                   editCarrierData.slogan, | ||||
|                   e.target.value as string | ||||
|                 ) | ||||
|               } | ||||
|             > | ||||
|               {mediaStore.media | ||||
|                 .filter((media) => media.media_type === 3) | ||||
|                 .map((media) => ( | ||||
|                   <MenuItem key={media.id} value={media.id}> | ||||
|                     {media.media_name || media.filename} | ||||
|                   </MenuItem> | ||||
|                 ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           {editCarrierData.logo && ( | ||||
|             <div className="w-32 h-32"> | ||||
|               <MediaViewer | ||||
|                 media={{ id: editCarrierData.logo, media_type: 1 }} | ||||
|               /> | ||||
|             </div> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Логотип перевозчика" | ||||
|             imageKey="thumbnail" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setIsDeleteLogoModalOpen(true); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("image"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("image"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -289,19 +233,56 @@ export const CarrierEditPage = observer(() => { | ||||
|           onClick={handleEdit} | ||||
|           disabled={ | ||||
|             isLoading || | ||||
|             !editCarrierData.full_name || | ||||
|             !editCarrierData.short_name || | ||||
|             !editCarrierData.city_id || | ||||
|             !editCarrierData.logo | ||||
|             !editCarrierData[language].full_name || | ||||
|             !editCarrierData.city_id | ||||
|           } | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Обновить" | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       <SelectMediaDialog | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={1} | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         contextObjectName={editCarrierData[language].full_name} | ||||
|         contextType="carrier" | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={activeMenuType} | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isDeleteLogoModalOpen} | ||||
|         onDelete={() => { | ||||
|           setEditCarrierData( | ||||
|             editCarrierData[language].full_name, | ||||
|             editCarrierData[language].short_name, | ||||
|             editCarrierData.city_id, | ||||
|             editCarrierData[language].slogan, | ||||
|             "", | ||||
|             language | ||||
|           ); | ||||
|           setIsDeleteLogoModalOpen(false); | ||||
|         }} | ||||
|         onCancel={() => setIsDeleteLogoModalOpen(false)} | ||||
|         edit | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,43 +1,94 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { carrierStore } from "@shared"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { carrierStore, cityStore, languageStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const CarrierListPage = observer(() => { | ||||
|   const { carriers, getCarriers, deleteCarrier } = carrierStore; | ||||
|   const { getCities, cities } = cityStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getCarriers(); | ||||
|   }, []); | ||||
|     const fetchData = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getCities("ru"); | ||||
|       await getCities("en"); | ||||
|       await getCities("zh"); | ||||
|       await getCarriers(language); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
|     { | ||||
|       field: "full_name", | ||||
|       headerName: "Полное имя", | ||||
|  | ||||
|       width: 300, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "short_name", | ||||
|       headerName: "Короткое имя", | ||||
|       width: 200, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "city", | ||||
|       field: "city_id", | ||||
|       headerName: "Город", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         const city = cities[language]?.data.find( | ||||
|           (city) => city.id == params.value | ||||
|         ); | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {city && city.name ? ( | ||||
|               city.name | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "Действия", | ||||
|       headerAlign: "center", | ||||
|       width: 200, | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -45,9 +96,9 @@ export const CarrierListPage = observer(() => { | ||||
|             <button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> | ||||
|               <Pencil size={20} className="text-blue-500" /> | ||||
|             </button> | ||||
|             <button onClick={() => navigate(`/carrier/${params.row.id}`)}> | ||||
|             {/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}> | ||||
|               <Eye size={20} className="text-green-500" /> | ||||
|             </button> | ||||
|             </button> */} | ||||
|             <button | ||||
|               onClick={() => { | ||||
|                 setIsDeleteModalOpen(true); | ||||
| @@ -62,25 +113,56 @@ export const CarrierListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = carriers.data?.map((carrier) => ({ | ||||
|   const rows = carriers[language].data?.map((carrier) => ({ | ||||
|     id: carrier.id, | ||||
|     full_name: carrier.full_name, | ||||
|     short_name: carrier.short_name, | ||||
|     city: carrier.city, | ||||
|     city_id: carrier.city_id, | ||||
|   })); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="w-full"> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Перевозчики</h1> | ||||
|           <CreateButton label="Создать перевозчика" path="/carrier/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids as unknown as number[])); | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? ( | ||||
|                   <CircularProgress size={20} /> | ||||
|                 ) : ( | ||||
|                   "Нет перевозчиков" | ||||
|                 )} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -98,6 +180,19 @@ export const CarrierListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteCarrier(id))); | ||||
|           await getCarriers(language); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,114 +0,0 @@ | ||||
| import { Paper } from "@mui/material"; | ||||
| import { carrierStore, mediaStore } from "@shared"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
| import { useEffect } from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
|  | ||||
| export const CarrierPreviewPage = observer(() => { | ||||
|   const { id } = useParams(); | ||||
|   const { getCarrier, carrier, setEditCarrierData } = carrierStore; | ||||
|   const { oneMedia, getOneMedia } = mediaStore; | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       const carrierResponse = await getCarrier(Number(id)); | ||||
|       setEditCarrierData( | ||||
|         carrierResponse?.full_name as string, | ||||
|         carrierResponse?.short_name as string, | ||||
|         carrierResponse?.city as string, | ||||
|         carrierResponse?.city_id as number, | ||||
|         carrierResponse?.main_color as string, | ||||
|         carrierResponse?.left_color as string, | ||||
|         carrierResponse?.right_color as string, | ||||
|         carrierResponse?.slogan as string, | ||||
|         carrierResponse?.logo as string | ||||
|       ); | ||||
|       console.log(carrierResponse); | ||||
|       await getOneMedia(carrierResponse?.logo as string); | ||||
|     })(); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       {carrier && ( | ||||
|         <> | ||||
|           <div className="flex justify-between items-center"> | ||||
|             <button | ||||
|               className="flex items-center gap-2" | ||||
|               onClick={() => navigate(-1)} | ||||
|             > | ||||
|               <ArrowLeft size={20} /> | ||||
|               Назад | ||||
|             </button> | ||||
|           </div> | ||||
|           <div className="flex flex-col gap-10 w-full"> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Полное имя</h1> | ||||
|               <p>{carrier[Number(id)]?.full_name}</p> | ||||
|             </div> | ||||
|  | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Полное имя</h1> | ||||
|               <p>{carrier[Number(id)]?.full_name}</p> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Город</h1> | ||||
|               <p>{carrier[Number(id)]?.city}</p> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2 "> | ||||
|               <h1 className="text-lg font-bold">Основной цвет</h1> | ||||
|               <div | ||||
|                 className="w-min" | ||||
|                 style={{ | ||||
|                   backgroundColor: `${carrier[Number(id)]?.main_color}90`, | ||||
|                 }} | ||||
|               > | ||||
|                 {carrier[Number(id)]?.main_color} | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Цвет левого виджета</h1> | ||||
|               <div | ||||
|                 className="w-min" | ||||
|                 style={{ | ||||
|                   backgroundColor: `${carrier[Number(id)]?.left_color}90`, | ||||
|                 }} | ||||
|               > | ||||
|                 {carrier[Number(id)]?.left_color} | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Цвет правого виджета</h1> | ||||
|               <div | ||||
|                 className="w-min" | ||||
|                 style={{ | ||||
|                   backgroundColor: `${carrier[Number(id)]?.right_color}90`, | ||||
|                 }} | ||||
|               > | ||||
|                 {carrier[Number(id)]?.right_color} | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Краткое имя</h1> | ||||
|               <p>{carrier[Number(id)]?.short_name}</p> | ||||
|             </div> | ||||
|             <div className="flex flex-col gap-2"> | ||||
|               <h1 className="text-lg font-bold">Логотип</h1> | ||||
|  | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: oneMedia?.id as string, | ||||
|                   media_type: oneMedia?.media_type as number, | ||||
|                   filename: oneMedia?.filename, | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
| @@ -1,4 +1,4 @@ | ||||
| export * from "./CarrierListPage"; | ||||
| export * from "./CarrierPreviewPage"; | ||||
|  | ||||
| export * from "./CarrierCreatePage"; | ||||
| export * from "./CarrierEditPage"; | ||||
|   | ||||
| @@ -6,17 +6,20 @@ import { | ||||
|   MenuItem, | ||||
|   FormControl, | ||||
|   InputLabel, | ||||
|   Box, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save, ImagePlus } from "lucide-react"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { cityStore, countryStore, languageStore, mediaStore } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { LanguageSwitcher, MediaViewer } from "@widgets"; | ||||
| import { SelectMediaDialog } from "@shared"; | ||||
| import { LanguageSwitcher, ImageUploadCard } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CityCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
| @@ -24,12 +27,20 @@ export const CityCreatePage = observer(() => { | ||||
|   const { createCityData, setCreateCityData } = cityStore; | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null | ||||
|   >(null); | ||||
|   const { getCountries } = countryStore; | ||||
|   const { getMedia } = mediaStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       await getCountries(language); | ||||
|       await getCountries("ru"); | ||||
|       await getCountries("en"); | ||||
|       await getCountries("zh"); | ||||
|       await getMedia(); | ||||
|     })(); | ||||
|   }, [language]); | ||||
| @@ -55,7 +66,6 @@ export const CityCreatePage = observer(() => { | ||||
|   }) => { | ||||
|     setCreateCityData( | ||||
|       createCityData[language].name, | ||||
|       createCityData.country, | ||||
|       createCityData.country_code, | ||||
|       media.id, | ||||
|       language | ||||
| @@ -80,6 +90,9 @@ export const CityCreatePage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|           <h1 className="text-3xl break-words">{createCityData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Название города" | ||||
| @@ -88,7 +101,6 @@ export const CityCreatePage = observer(() => { | ||||
|           onChange={(e) => | ||||
|             setCreateCityData( | ||||
|               e.target.value, | ||||
|               createCityData.country, | ||||
|               createCityData.country_code, | ||||
|               createCityData.arms, | ||||
|               language | ||||
| @@ -103,19 +115,15 @@ export const CityCreatePage = observer(() => { | ||||
|             label="Страна" | ||||
|             required | ||||
|             onChange={(e) => { | ||||
|               const selectedCountry = countryStore.countries[language]?.find( | ||||
|                 (country) => country.code === e.target.value | ||||
|               ); | ||||
|               setCreateCityData( | ||||
|                 createCityData[language].name, | ||||
|                 selectedCountry?.name || "", | ||||
|                 e.target.value, | ||||
|                 createCityData.arms, | ||||
|                 language | ||||
|               ); | ||||
|             }} | ||||
|           > | ||||
|             {countryStore.countries[language].map((country) => ( | ||||
|             {countryStore.countries["ru"]?.data?.map((country) => ( | ||||
|               <MenuItem key={country.code} value={country.code}> | ||||
|                 {country.name} | ||||
|               </MenuItem> | ||||
| @@ -123,44 +131,36 @@ export const CityCreatePage = observer(() => { | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <label className="text-sm text-gray-600">Герб города</label> | ||||
|           <div className="flex items-center gap-4"> | ||||
|             <Button | ||||
|               variant="outlined" | ||||
|               onClick={() => setIsSelectMediaOpen(true)} | ||||
|               startIcon={<ImagePlus size={20} />} | ||||
|             > | ||||
|               Выбрать герб | ||||
|             </Button> | ||||
|             {selectedMedia && ( | ||||
|               <span className="text-sm text-gray-600"> | ||||
|                 {selectedMedia.media_name || selectedMedia.filename} | ||||
|               </span> | ||||
|             )} | ||||
|           </div> | ||||
|           {selectedMedia && ( | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 width: "200px", | ||||
|                 height: "200px", | ||||
|                 border: "1px solid #e0e0e0", | ||||
|                 borderRadius: "8px", | ||||
|                 overflow: "hidden", | ||||
|                 display: "flex", | ||||
|                 alignItems: "center", | ||||
|                 justifyContent: "center", | ||||
|               }} | ||||
|             > | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: selectedMedia.id, | ||||
|                   media_type: selectedMedia.media_type, | ||||
|                   filename: selectedMedia.filename, | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Герб города" | ||||
|             imageKey="image" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setCreateCityData( | ||||
|                 createCityData[language].name, | ||||
|                 createCityData.country_code, | ||||
|                 "", | ||||
|                 language | ||||
|               ); | ||||
|               setActiveMenuType(null); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("image"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("image"); | ||||
|             }} | ||||
|             setHardcodeType={() => { | ||||
|               setActiveMenuType("image"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -182,7 +182,24 @@ export const CityCreatePage = observer(() => { | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={3} // Тип медиа для иконок | ||||
|         mediaType={1} // Тип медиа для иконок | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         contextObjectName={createCityData[language]?.name} | ||||
|         contextType="city" | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={ | ||||
|           activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null | ||||
|         } | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
|   | ||||
| @@ -6,10 +6,9 @@ import { | ||||
|   MenuItem, | ||||
|   FormControl, | ||||
|   InputLabel, | ||||
|   Box, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save, ImagePlus } from "lucide-react"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| @@ -21,19 +20,34 @@ import { | ||||
|   CashedCities, | ||||
| } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { LanguageSwitcher, MediaViewer } from "@widgets"; | ||||
| import { SelectMediaDialog } from "@shared"; | ||||
| import { LanguageSwitcher, ImageUploadCard } from "@widgets"; | ||||
| import { | ||||
|   SelectMediaDialog, | ||||
|   UploadMediaDialog, | ||||
|   PreviewMediaDialog, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const CityEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); | ||||
|   const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); | ||||
|   const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); | ||||
|   const [mediaId, setMediaId] = useState(""); | ||||
|   const [activeMenuType, setActiveMenuType] = useState< | ||||
|     "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null | ||||
|   >(null); | ||||
|   const { language } = languageStore; | ||||
|   const { id } = useParams(); | ||||
|   const { editCityData, editCity, getCity, setEditCityData } = cityStore; | ||||
|   const { getCountries } = countryStore; | ||||
|   const { getMedia, getOneMedia } = mediaStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   const handleEdit = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
| @@ -49,20 +63,23 @@ export const CityEditPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const data = await getCity(id as string, language); | ||||
|         setEditCityData( | ||||
|           data.name, | ||||
|           data.country, | ||||
|           data.country_code, | ||||
|           data.arms, | ||||
|           language | ||||
|         ); | ||||
|         await getOneMedia(data.arms as string); | ||||
|         await getCountries(language); | ||||
|         await getCountries("ru"); | ||||
|         // Fetch data for all languages | ||||
|         const ruData = await getCity(id as string, "ru"); | ||||
|         const enData = await getCity(id as string, "en"); | ||||
|         const zhData = await getCity(id as string, "zh"); | ||||
|  | ||||
|         // Set data for each language | ||||
|         setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); | ||||
|         setEditCityData(enData.name, enData.country_code, enData.arms, "en"); | ||||
|         setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); | ||||
|  | ||||
|         await getOneMedia(ruData.arms as string); | ||||
|  | ||||
|         await getMedia(); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   const handleMediaSelect = (media: { | ||||
|     id: string; | ||||
| @@ -72,7 +89,6 @@ export const CityEditPage = observer(() => { | ||||
|   }) => { | ||||
|     setEditCityData( | ||||
|       editCityData[language].name, | ||||
|       editCityData.country, | ||||
|       editCityData.country_code, | ||||
|       media.id, | ||||
|       language | ||||
| @@ -97,6 +113,9 @@ export const CityEditPage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start    "> | ||||
|           <h1 className="text-3xl break-words">{editCityData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Название" | ||||
| @@ -105,7 +124,6 @@ export const CityEditPage = observer(() => { | ||||
|           onChange={(e) => | ||||
|             setEditCityData( | ||||
|               e.target.value, | ||||
|               editCityData.country, | ||||
|               editCityData.country_code, | ||||
|               editCityData.arms, | ||||
|               language | ||||
| @@ -120,19 +138,15 @@ export const CityEditPage = observer(() => { | ||||
|             label="Страна" | ||||
|             required | ||||
|             onChange={(e) => { | ||||
|               const selectedCountry = countryStore.countries[language]?.find( | ||||
|                 (country) => country.code === e.target.value | ||||
|               ); | ||||
|               setEditCityData( | ||||
|                 editCityData[language as keyof CashedCities]?.name || "", | ||||
|                 selectedCountry?.name || "", | ||||
|                 editCityData[language].name, | ||||
|                 e.target.value, | ||||
|                 editCityData.arms, | ||||
|                 language | ||||
|               ); | ||||
|             }} | ||||
|           > | ||||
|             {countryStore.countries[language].map((country) => ( | ||||
|             {countryStore.countries.ru.data.map((country) => ( | ||||
|               <MenuItem key={country.code} value={country.code}> | ||||
|                 {country.name} | ||||
|               </MenuItem> | ||||
| @@ -140,44 +154,36 @@ export const CityEditPage = observer(() => { | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         <div className="w-full flex flex-col gap-4"> | ||||
|           <label className="text-sm text-gray-600">Герб города</label> | ||||
|           <div className="flex items-center gap-4"> | ||||
|             <Button | ||||
|               variant="outlined" | ||||
|               onClick={() => setIsSelectMediaOpen(true)} | ||||
|               startIcon={<ImagePlus size={20} />} | ||||
|             > | ||||
|               Выбрать герб | ||||
|             </Button> | ||||
|             {selectedMedia && ( | ||||
|               <span className="text-sm text-gray-600"> | ||||
|                 {selectedMedia.media_name || selectedMedia.filename} | ||||
|               </span> | ||||
|             )} | ||||
|           </div> | ||||
|           {selectedMedia && ( | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 width: "200px", | ||||
|                 height: "200px", | ||||
|                 border: "1px solid #e0e0e0", | ||||
|                 borderRadius: "8px", | ||||
|                 overflow: "hidden", | ||||
|                 display: "flex", | ||||
|                 alignItems: "center", | ||||
|                 justifyContent: "center", | ||||
|               }} | ||||
|             > | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: selectedMedia.id, | ||||
|                   media_type: selectedMedia.media_type, | ||||
|                   filename: selectedMedia.filename, | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           )} | ||||
|         <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> | ||||
|           <ImageUploadCard | ||||
|             title="Герб города" | ||||
|             imageKey="image" | ||||
|             imageUrl={selectedMedia?.id} | ||||
|             onImageClick={() => { | ||||
|               setIsPreviewMediaOpen(true); | ||||
|               setMediaId(selectedMedia?.id ?? ""); | ||||
|             }} | ||||
|             onDeleteImageClick={() => { | ||||
|               setEditCityData( | ||||
|                 editCityData[language].name, | ||||
|                 editCityData.country_code, | ||||
|                 "", | ||||
|                 language | ||||
|               ); | ||||
|               setActiveMenuType(null); | ||||
|             }} | ||||
|             onSelectFileClick={() => { | ||||
|               setActiveMenuType("image"); | ||||
|               setIsSelectMediaOpen(true); | ||||
|             }} | ||||
|             setUploadMediaOpen={() => { | ||||
|               setIsUploadMediaOpen(true); | ||||
|               setActiveMenuType("image"); | ||||
|             }} | ||||
|             setHardcodeType={() => { | ||||
|               setActiveMenuType("image"); | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <Button | ||||
| @@ -192,7 +198,7 @@ export const CityEditPage = observer(() => { | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Обновить" | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
| @@ -201,6 +207,29 @@ export const CityEditPage = observer(() => { | ||||
|         open={isSelectMediaOpen} | ||||
|         onClose={() => setIsSelectMediaOpen(false)} | ||||
|         onSelectMedia={handleMediaSelect} | ||||
|         mediaType={1} // Тип медиа для иконок | ||||
|       /> | ||||
|  | ||||
|       <UploadMediaDialog | ||||
|         open={isUploadMediaOpen} | ||||
|         onClose={() => setIsUploadMediaOpen(false)} | ||||
|         contextObjectName={editCityData[language].name} | ||||
|         contextType="city" | ||||
|         afterUpload={handleMediaSelect} | ||||
|         hardcodeType={ | ||||
|           activeMenuType as | ||||
|             | "thumbnail" | ||||
|             | "watermark_lu" | ||||
|             | "watermark_rd" | ||||
|             | "image" | ||||
|             | null | ||||
|         } | ||||
|       /> | ||||
|  | ||||
|       <PreviewMediaDialog | ||||
|         open={isPreviewMediaOpen} | ||||
|         onClose={() => setIsPreviewMediaOpen(false)} | ||||
|         mediaId={mediaId} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
|   | ||||
| @@ -1,32 +1,91 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, cityStore, CashedCities } from "@shared"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { languageStore, cityStore, countryStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const CityListPage = observer(() => { | ||||
|   const { cities, getCities, deleteCity } = cityStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [rows, setRows] = useState<any[]>([]); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getCities(language); | ||||
|     const fetchData = async () => { | ||||
|       setIsLoading(true); | ||||
|       await countryStore.getCountries("ru"); | ||||
|       await countryStore.getCountries("en"); | ||||
|       await countryStore.getCountries("zh"); | ||||
|       await getCities(language); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [language]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let newRows = cities[language]?.data?.map((city) => ({ | ||||
|       id: city.id, | ||||
|       name: city.name, | ||||
|       country: city.country_code, | ||||
|     })); | ||||
|  | ||||
|     let newRows2: any[] = []; | ||||
|     for (const city of newRows) { | ||||
|       const name = countryStore.countries[language]?.data?.find( | ||||
|         (country) => country.code === city.country | ||||
|       )?.name; | ||||
|       if (name) { | ||||
|         newRows2.push(city); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     setRows(newRows2 || []); | ||||
|   }, [cities, countryStore.countries, language, isLoading]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
|     { | ||||
|       field: "country", | ||||
|       headerName: "Страна", | ||||
|       width: 150, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               countryStore.countries[language]?.data?.find( | ||||
|                 (country) => country.code === params.value | ||||
|               )?.name | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
| @@ -34,18 +93,19 @@ export const CityListPage = observer(() => { | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       width: 200, | ||||
|  | ||||
|       sortable: false, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="flex h-full gap-7 justify-center items-center"> | ||||
|             <button onClick={() => navigate(`/city/${params.row.id}/edit`)}> | ||||
|               <Pencil size={20} className="text-blue-500" /> | ||||
|             </button> | ||||
|             <button onClick={() => navigate(`/city/${params.row.id}`)}> | ||||
|             {/* <button onClick={() => navigate(`/city/${params.row.id}`)}> | ||||
|               <Eye size={20} className="text-green-500" /> | ||||
|             </button> | ||||
|             </button> */} | ||||
|             <button | ||||
|               onClick={() => { | ||||
|               onClick={(e) => { | ||||
|                 e.stopPropagation(); | ||||
|                 setIsDeleteModalOpen(true); | ||||
|                 setRowId(params.row.id); | ||||
|               }} | ||||
| @@ -58,12 +118,6 @@ export const CityListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = cities[language].map((city) => ({ | ||||
|     id: city.id, | ||||
|     name: city.name, | ||||
|     country: city.country, | ||||
|   })); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <LanguageSwitcher /> | ||||
| @@ -73,11 +127,37 @@ export const CityListPage = observer(() => { | ||||
|           <h1 className="text-2xl">Города</h1> | ||||
|           <CreateButton label="Создать город" path="/city/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids as unknown as number[])); | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Нет городов"} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -85,7 +165,8 @@ export const CityListPage = observer(() => { | ||||
|         open={isDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           if (rowId) { | ||||
|             deleteCity(rowId.toString(), language as keyof CashedCities); | ||||
|             await deleteCity(rowId.toString()); | ||||
|             toast.success("Город успешно удален"); | ||||
|           } | ||||
|           setIsDeleteModalOpen(false); | ||||
|           setRowId(null); | ||||
| @@ -95,6 +176,20 @@ export const CityListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteCity(id.toString()))); | ||||
|           toast.success("Города успешно удалены"); | ||||
|           getCities(language); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -16,18 +16,18 @@ export const CityPreviewPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const cityResponse = await getCity(id as string, language); | ||||
|         setEditCityData( | ||||
|           cityResponse.name, | ||||
|           cityResponse.country, | ||||
|           cityResponse.country_code, | ||||
|           cityResponse.arms, | ||||
|           language | ||||
|         ); | ||||
|         await getOneMedia(cityResponse.arms as string); | ||||
|         const ruData = await getCity(id as string, "ru"); | ||||
|         const enData = await getCity(id as string, "en"); | ||||
|         const zhData = await getCity(id as string, "zh"); | ||||
|  | ||||
|         setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); | ||||
|         setEditCityData(enData.name, enData.country_code, enData.arms, "en"); | ||||
|         setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); | ||||
|  | ||||
|         await getOneMedia(ruData.arms as string); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|   | ||||
							
								
								
									
										115
									
								
								src/pages/Country/CountryAddPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/pages/Country/CountryAddPage/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| import { | ||||
|   Button, | ||||
|   Paper, | ||||
|   TextField, | ||||
|   Autocomplete, | ||||
|   FormControl, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { | ||||
|   countryStore, | ||||
|   RU_COUNTRIES, | ||||
|   EN_COUNTRIES, | ||||
|   ZH_COUNTRIES, | ||||
| } from "@shared"; | ||||
| import { useState } from "react"; | ||||
|  | ||||
| export const CountryAddPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { createCountryData, setCountryData, createCountry } = countryStore; | ||||
|  | ||||
|   const handleCountryCodeChange = (code: string) => { | ||||
|     const ruCountry = RU_COUNTRIES.find((c) => c.code === code); | ||||
|     const enCountry = EN_COUNTRIES.find((c) => c.code === code); | ||||
|     const zhCountry = ZH_COUNTRIES.find((c) => c.code === code); | ||||
|  | ||||
|     if (ruCountry && enCountry && zhCountry) { | ||||
|       setCountryData(code, ruCountry.name, "ru"); | ||||
|       setCountryData(code, enCountry.name, "en"); | ||||
|       setCountryData(code, zhCountry.name, "zh"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleCreate = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
|       await createCountry(); | ||||
|       toast.success("Страна успешно создана"); | ||||
|       navigate("/country"); | ||||
|     } catch (error) { | ||||
|       toast.error("Ошибка при создании страны"); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <FormControl fullWidth> | ||||
|           <Autocomplete | ||||
|             value={ | ||||
|               RU_COUNTRIES.find((c) => c.code === createCountryData.code) || | ||||
|               null | ||||
|             } | ||||
|             onChange={(_, newValue) => { | ||||
|               if (newValue) { | ||||
|                 handleCountryCodeChange(newValue.code); | ||||
|               } | ||||
|             }} | ||||
|             options={RU_COUNTRIES} | ||||
|             getOptionLabel={(option) => `${option.code} - ${option.name}`} | ||||
|             renderInput={(params) => ( | ||||
|               <TextField | ||||
|                 {...params} | ||||
|                 label="Страна" | ||||
|                 required | ||||
|                 inputProps={{ | ||||
|                   ...params.inputProps, | ||||
|                   maxLength: 2, | ||||
|                 }} | ||||
|               /> | ||||
|             )} | ||||
|             filterOptions={(options, { inputValue }) => { | ||||
|               const searchValue = inputValue.toUpperCase(); | ||||
|               return options.filter( | ||||
|                 (option) => | ||||
|                   option.code.includes(searchValue) || | ||||
|                   option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||
|               ); | ||||
|             }} | ||||
|           /> | ||||
|         </FormControl> | ||||
|  | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleCreate} | ||||
|           disabled={isLoading || !createCountryData.code} | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Создать" | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
| @@ -41,6 +41,9 @@ export const CountryCreatePage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|           <h1 className="text-3xl break-words">{createCountryData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Код страны" | ||||
|   | ||||
| @@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => { | ||||
|   const { editCountryData, editCountry, getCountry, setEditCountryData } = | ||||
|     countryStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   const handleEdit = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
| @@ -31,11 +36,18 @@ export const CountryEditPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const data = await getCountry(id as string, language); | ||||
|         setEditCountryData(data.name, language); | ||||
|         // Fetch data for all languages | ||||
|         const ruData = await getCountry(id as string, "ru"); | ||||
|         const enData = await getCountry(id as string, "en"); | ||||
|         const zhData = await getCountry(id as string, "zh"); | ||||
|  | ||||
|         // Set data for each language | ||||
|         setEditCountryData(ruData.name, "ru"); | ||||
|         setEditCountryData(enData.name, "en"); | ||||
|         setEditCountryData(zhData.name, "zh"); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
| @@ -51,6 +63,9 @@ export const CountryEditPage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|           <h1 className="text-3xl break-words t">{editCountryData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Код страны" | ||||
| @@ -78,7 +93,7 @@ export const CountryEditPage = observer(() => { | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Обновить" | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|   | ||||
| @@ -1,20 +1,30 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { countryStore, languageStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { Trash2, Minus } from "lucide-react"; | ||||
|  | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const CountryListPage = observer(() => { | ||||
|   const { countries, getCountries } = countryStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const { countries, getCountries, deleteCountry } = countryStore; | ||||
|  | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getCountries(language); | ||||
|     const fetchCountries = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getCountries(language); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchCountries(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -22,6 +32,17 @@ export const CountryListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center "> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
| @@ -29,19 +50,21 @@ export const CountryListPage = observer(() => { | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       width: 200, | ||||
|       sortable: false, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="flex h-full gap-7 justify-center items-center"> | ||||
|             <button | ||||
|             {/* <button | ||||
|               onClick={() => navigate(`/country/${params.row.code}/edit`)} | ||||
|             > | ||||
|               <Pencil size={20} className="text-blue-500" /> | ||||
|             </button> | ||||
|             <button onClick={() => navigate(`/country/${params.row.code}`)}> | ||||
|             </button> */} | ||||
|             {/* <button onClick={() => navigate(`/country/${params.row.code}`)}> | ||||
|               <Eye size={20} className="text-green-500" /> | ||||
|             </button> | ||||
|             </button> */} | ||||
|             <button | ||||
|               onClick={() => { | ||||
|               onClick={(e) => { | ||||
|                 e.stopPropagation(); | ||||
|                 setIsDeleteModalOpen(true); | ||||
|                 setRowId(params.row.code); | ||||
|               }} | ||||
| @@ -54,7 +77,7 @@ export const CountryListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = countries[language]?.map((country) => ({ | ||||
|   const rows = countries[language]?.data.map((country) => ({ | ||||
|     id: country.code, | ||||
|     code: country.code, | ||||
|     name: country.name, | ||||
| @@ -67,25 +90,66 @@ export const CountryListPage = observer(() => { | ||||
|       <div className="w-full"> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Страны</h1> | ||||
|           <CreateButton label="Создать страну" path="/country/create" /> | ||||
|           <CreateButton label="Добавить страну" path="/country/add" /> | ||||
|         </div> | ||||
|         <DataGrid rows={rows} columns={columns} hideFooter /> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows || []} | ||||
|           columns={columns} | ||||
|           hideFooter | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids as unknown as number[])); | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Нет стран"} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           if (rowId) { | ||||
|             await countryStore.deleteCountry(rowId, language); | ||||
|             getCountries(language); // Refresh the list after deletion | ||||
|             setIsDeleteModalOpen(false); | ||||
|           } | ||||
|           setIsDeleteModalOpen(false); | ||||
|           if (!rowId) return; | ||||
|           await deleteCountry(rowId); | ||||
|           setRowId(null); | ||||
|           setIsDeleteModalOpen(false); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsDeleteModalOpen(false); | ||||
|           setRowId(null); | ||||
|           setIsDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteCountry(id.toString()))); | ||||
|           getCountries(language); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   | ||||
| @@ -15,11 +15,16 @@ export const CountryPreviewPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       if (id) { | ||||
|         const data = await getCountry(id as string, language); | ||||
|         setEditCountryData(data.name, language); | ||||
|         const ruData = await getCountry(id as string, "ru"); | ||||
|         const enData = await getCountry(id as string, "en"); | ||||
|         const zhData = await getCountry(id as string, "zh"); | ||||
|  | ||||
|         setEditCountryData(ruData.name, "ru"); | ||||
|         setEditCountryData(enData.name, "en"); | ||||
|         setEditCountryData(zhData.name, "zh"); | ||||
|       } | ||||
|     })(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
| @@ -55,7 +60,7 @@ export const CountryPreviewPage = observer(() => { | ||||
|         <div className="flex flex-col gap-10 w-full"> | ||||
|           <div className="flex flex-col gap-2"> | ||||
|             <h1 className="text-lg font-bold">Название</h1> | ||||
|             <p>{country[id!]?.[language]?.name}</p> | ||||
|             <p>{country[id!]?.ru?.name}</p> | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|   | ||||
| @@ -2,3 +2,4 @@ export * from "./CountryListPage"; | ||||
| export * from "./CountryPreviewPage"; | ||||
| export * from "./CountryCreatePage"; | ||||
| export * from "./CountryEditPage"; | ||||
| export * from "./CountryAddPage"; | ||||
|   | ||||
| @@ -3,12 +3,7 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets"; | ||||
| import { LeftWidgetTab } from "@widgets"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { | ||||
|   articlesStore, | ||||
|   cityStore, | ||||
|   editSightStore, | ||||
|   languageStore, | ||||
| } from "@shared"; | ||||
| import { articlesStore, cityStore, editSightStore } from "@shared"; | ||||
| import { useBlocker, useParams } from "react-router-dom"; | ||||
|  | ||||
| function a11yProps(index: number) { | ||||
| @@ -22,9 +17,9 @@ export const EditSightPage = observer(() => { | ||||
|   const [value, setValue] = useState(0); | ||||
|   const { sight, getSightInfo, needLeaveAgree } = editSightStore; | ||||
|   const { getArticles } = articlesStore; | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   const { id } = useParams(); | ||||
|   const { getRuCities } = cityStore; | ||||
|   const { getCities } = cityStore; | ||||
|  | ||||
|   let blocker = useBlocker( | ||||
|     ({ currentLocation, nextLocation }) => | ||||
| @@ -38,13 +33,17 @@ export const EditSightPage = observer(() => { | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       if (id) { | ||||
|         await getSightInfo(+id, language); | ||||
|         await getArticles(language); | ||||
|         await getRuCities(); | ||||
|         await getCities("ru"); | ||||
|         await getSightInfo(+id, "ru"); | ||||
|         await getSightInfo(+id, "en"); | ||||
|         await getSightInfo(+id, "zh"); | ||||
|         await getArticles("ru"); | ||||
|         await getArticles("en"); | ||||
|         await getArticles("zh"); | ||||
|       } | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|   | ||||
| @@ -5,9 +5,12 @@ import { | ||||
|   Typography, | ||||
|   Alert, | ||||
|   CircularProgress, | ||||
|   FormControlLabel, | ||||
|   Checkbox, | ||||
|   Paper, | ||||
| } from "@mui/material"; | ||||
| import { authStore } from "@shared"; | ||||
| import { useState } from "react"; | ||||
| import { authStore, userStore } from "@shared"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
|  | ||||
| @@ -15,9 +18,21 @@ export const LoginPage = () => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [email, setEmail] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [rememberMe, setRememberMe] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { login } = authStore; | ||||
|   const { getUsers } = userStore; | ||||
|   useEffect(() => { | ||||
|     // Load saved credentials if they exist | ||||
|     const savedEmail = localStorage.getItem("rememberedEmail"); | ||||
|     const savedPassword = localStorage.getItem("rememberedPassword"); | ||||
|     if (savedEmail && savedPassword) { | ||||
|       setEmail(savedEmail); | ||||
|       setPassword(savedPassword); | ||||
|       setRememberMe(true); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   const handleSubmit = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
| @@ -26,7 +41,23 @@ export const LoginPage = () => { | ||||
|  | ||||
|     try { | ||||
|       await login(email, password); | ||||
|       navigate("/sight"); | ||||
|  | ||||
|       // Save or clear credentials based on remember me checkbox | ||||
|       if (rememberMe) { | ||||
|         localStorage.setItem("rememberedEmail", email); | ||||
|         localStorage.setItem("rememberedPassword", password); | ||||
|       } else { | ||||
|         localStorage.removeItem("rememberedEmail"); | ||||
|         localStorage.removeItem("rememberedPassword"); | ||||
|       } | ||||
|  | ||||
|       navigate("/map"); | ||||
|       try { | ||||
|         await getUsers(); | ||||
|       } catch (err) { | ||||
|         console.error(err); | ||||
|       } | ||||
|  | ||||
|       toast.success("Вход в систему выполнен успешно"); | ||||
|     } catch (err) { | ||||
|       setError( | ||||
| @@ -47,73 +78,102 @@ export const LoginPage = () => { | ||||
|         flexDirection: "column", | ||||
|         alignItems: "center", | ||||
|         justifyContent: "center", | ||||
|         minHeight: "100vh", | ||||
|         width: "100vw", | ||||
|         height: "100vh", | ||||
|         gap: 3, | ||||
|         p: 3, | ||||
|         backgroundImage: "url('/login-bg.png')", | ||||
|         backgroundSize: "cover", | ||||
|         backgroundPosition: "center", | ||||
|         backgroundRepeat: "no-repeat", | ||||
|       }} | ||||
|     > | ||||
|       <Typography variant="h4" component="h1" gutterBottom> | ||||
|         Вход в систему | ||||
|       </Typography> | ||||
|       <Box | ||||
|         component="form" | ||||
|         onSubmit={handleSubmit} | ||||
|       <Paper | ||||
|         elevation={3} | ||||
|         sx={{ | ||||
|           display: "flex", | ||||
|           flexDirection: "column", | ||||
|           gap: 2, | ||||
|           p: 4, | ||||
|           borderRadius: 2, | ||||
|           backgroundColor: "white", | ||||
|           width: "100%", | ||||
|           maxWidth: "400px", | ||||
|         }} | ||||
|       > | ||||
|         {error && ( | ||||
|           <Alert severity="error" sx={{ mb: 2 }}> | ||||
|             {error} | ||||
|           </Alert> | ||||
|         )} | ||||
|         <TextField | ||||
|           label="Email" | ||||
|           type="email" | ||||
|           variant="outlined" | ||||
|           fullWidth | ||||
|           required | ||||
|           autoComplete="email" | ||||
|           value={email} | ||||
|           onChange={(e) => setEmail(e.target.value)} | ||||
|           disabled={isLoading} | ||||
|           error={!!error} | ||||
|         /> | ||||
|         <TextField | ||||
|           label="Пароль" | ||||
|           type="password" | ||||
|           variant="outlined" | ||||
|           fullWidth | ||||
|           required | ||||
|           autoComplete="current-password" | ||||
|           value={password} | ||||
|           onChange={(e) => setPassword(e.target.value)} | ||||
|           disabled={isLoading} | ||||
|           error={!!error} | ||||
|         /> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           size="large" | ||||
|           type="submit" | ||||
|           disabled={isLoading} | ||||
|         <Typography | ||||
|           variant="h4" | ||||
|           component="h1" | ||||
|           className="text-center pb-[50px]" | ||||
|         > | ||||
|           Вход в систему | ||||
|         </Typography> | ||||
|         <Box | ||||
|           component="form" | ||||
|           onSubmit={handleSubmit} | ||||
|           sx={{ | ||||
|             width: "100%", | ||||
|             height: "50px", | ||||
|             position: "relative", | ||||
|             display: "flex", | ||||
|             alignItems: "center", | ||||
|             justifyContent: "center", | ||||
|             borderRadius: "10px", | ||||
|             flexDirection: "column", | ||||
|             gap: 2, | ||||
|             width: "100%", | ||||
|           }} | ||||
|         > | ||||
|           {isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"} | ||||
|         </Button> | ||||
|       </Box> | ||||
|           {error && ( | ||||
|             <Alert severity="error" sx={{ mb: 2 }}> | ||||
|               {error} | ||||
|             </Alert> | ||||
|           )} | ||||
|           <TextField | ||||
|             label="Email" | ||||
|             type="email" | ||||
|             variant="outlined" | ||||
|             fullWidth | ||||
|             required | ||||
|             autoComplete="email" | ||||
|             value={email} | ||||
|             onChange={(e) => setEmail(e.target.value)} | ||||
|             disabled={isLoading} | ||||
|             error={!!error} | ||||
|           /> | ||||
|           <TextField | ||||
|             label="Пароль" | ||||
|             type="password" | ||||
|             variant="outlined" | ||||
|             fullWidth | ||||
|             required | ||||
|             autoComplete="current-password" | ||||
|             value={password} | ||||
|             onChange={(e) => setPassword(e.target.value)} | ||||
|             disabled={isLoading} | ||||
|             error={!!error} | ||||
|           /> | ||||
|           <FormControlLabel | ||||
|             control={ | ||||
|               <Checkbox | ||||
|                 checked={rememberMe} | ||||
|                 onChange={(e) => setRememberMe(e.target.checked)} | ||||
|                 disabled={isLoading} | ||||
|               /> | ||||
|             } | ||||
|             label="Запомнить пароль" | ||||
|           /> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             size="large" | ||||
|             type="submit" | ||||
|             disabled={isLoading} | ||||
|             sx={{ | ||||
|               width: "100%", | ||||
|               height: "50px", | ||||
|               position: "relative", | ||||
|               display: "flex", | ||||
|               alignItems: "center", | ||||
|               justifyContent: "center", | ||||
|               borderRadius: "10px", | ||||
|             }} | ||||
|           > | ||||
|             {isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"} | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Paper> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -22,6 +22,28 @@ interface ApiSight { | ||||
|   longitude: number; | ||||
| } | ||||
|  | ||||
| // Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел. | ||||
| const COORDINATE_PRECISION_TOLERANCE = 1e-9; | ||||
|  | ||||
| // Вспомогательная функция, обновленная для сравнения с допуском. | ||||
| const arePathsEqual = ( | ||||
|   path1: [number, number][], | ||||
|   path2: [number, number][] | ||||
| ): boolean => { | ||||
|   if (path1.length !== path2.length) { | ||||
|     return false; | ||||
|   } | ||||
|   for (let i = 0; i < path1.length; i++) { | ||||
|     if ( | ||||
|       Math.abs(path1[i][0] - path2[i][0]) > COORDINATE_PRECISION_TOLERANCE || | ||||
|       Math.abs(path1[i][1] - path2[i][1]) > COORDINATE_PRECISION_TOLERANCE | ||||
|     ) { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|   return true; | ||||
| }; | ||||
|  | ||||
| class MapStore { | ||||
|   constructor() { | ||||
|     makeAutoObservable(this); | ||||
| @@ -32,26 +54,22 @@ class MapStore { | ||||
|   sights: ApiSight[] = []; | ||||
|  | ||||
|   getRoutes = async () => { | ||||
|     const routes = await languageInstance("ru").get("/route"); | ||||
|     const response = await languageInstance("ru").get("/route"); | ||||
|  | ||||
|     const routedIds = routes.data.map((route: any) => route.id); | ||||
|  | ||||
|     const mappedRoutes: ApiRoute[] = []; | ||||
|     for (const routeId of routedIds) { | ||||
|       const responseSoloRoute = await languageInstance("ru").get( | ||||
|         `/route/${routeId}` | ||||
|       ); | ||||
|       const route = responseSoloRoute.data; | ||||
|  | ||||
|       const mappedRoute = { | ||||
|         id: route.id, | ||||
|         route_number: route.route_number, | ||||
|         path: route.path, | ||||
|       }; | ||||
|  | ||||
|       mappedRoutes.push(mappedRoute); | ||||
|     const routesIds = response.data.map((route: any) => route.id); | ||||
|     for (const id of routesIds) { | ||||
|       const route = await languageInstance("ru").get(`/route/${id}`); | ||||
|       this.routes.push({ | ||||
|         id: route.data.id, | ||||
|         route_number: route.data.route_number, | ||||
|         path: route.data.path, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({ | ||||
|       id: route.id, | ||||
|       route_number: route.route_number, | ||||
|       path: route.path, | ||||
|     })); | ||||
|     this.routes = mappedRoutes.sort((a, b) => | ||||
|       a.route_number.localeCompare(b.route_number) | ||||
|     ); | ||||
| @@ -59,27 +77,23 @@ class MapStore { | ||||
|  | ||||
|   getStations = async () => { | ||||
|     const stations = await languageInstance("ru").get("/station"); | ||||
|     const mappedStations = stations.data.map((station: any) => ({ | ||||
|     this.stations = stations.data.map((station: any) => ({ | ||||
|       id: station.id, | ||||
|       name: station.name, | ||||
|       latitude: station.latitude, | ||||
|       longitude: station.longitude, | ||||
|     })); | ||||
|  | ||||
|     this.stations = mappedStations; | ||||
|   }; | ||||
|  | ||||
|   getSights = async () => { | ||||
|     const sights = await languageInstance("ru").get("/sight"); | ||||
|     const mappedSights = sights.data.map((sight: any) => ({ | ||||
|     this.sights = sights.data.map((sight: any) => ({ | ||||
|       id: sight.id, | ||||
|       name: sight.name, | ||||
|       description: sight.description, | ||||
|       latitude: sight.latitude, | ||||
|       longitude: sight.longitude, | ||||
|     })); | ||||
|  | ||||
|     this.sights = mappedSights; | ||||
|   }; | ||||
|  | ||||
|   deleteRecourse = async (recourse: string, id: number) => { | ||||
| @@ -94,31 +108,97 @@ class MapStore { | ||||
|   }; | ||||
|  | ||||
|   handleSave = async (json: string) => { | ||||
|     const sights: any[] = []; | ||||
|     const routes: any[] = []; | ||||
|     const stations: any[] = []; | ||||
|     const newSights: any[] = []; | ||||
|     const newRoutes: any[] = []; | ||||
|     const newStations: any[] = []; | ||||
|     const updatedSights: any[] = []; | ||||
|     const updatedRoutes: any[] = []; | ||||
|     const updatedStations: any[] = []; | ||||
|  | ||||
|     const parsedJSON = JSON.parse(json); | ||||
|  | ||||
|     console.log(parsedJSON); | ||||
|     parsedJSON.features.forEach((feature: any) => { | ||||
|     for (const feature of parsedJSON.features) { | ||||
|       const { geometry, properties, id } = feature; | ||||
|       const idCanBeSplited = id.split("-"); | ||||
|       const idParts = String(id).split("-"); | ||||
|  | ||||
|       if (!(idCanBeSplited.length > 1)) { | ||||
|       if (idParts.length > 1) { | ||||
|         const featureType = idParts[0]; | ||||
|         const numericId = parseInt(idParts[1], 10); | ||||
|         if (isNaN(numericId)) continue; | ||||
|  | ||||
|         if (featureType === "station") { | ||||
|           const originalStation = this.stations.find((s) => s.id === numericId); | ||||
|           if (!originalStation) continue; | ||||
|  | ||||
|           const currentStation = { | ||||
|             name: properties.name || "", | ||||
|             latitude: geometry.coordinates[1], | ||||
|             longitude: geometry.coordinates[0], | ||||
|           }; | ||||
|  | ||||
|           // ИЗМЕНЕНИЕ: Сравнение координат с допуском | ||||
|           if ( | ||||
|             originalStation.name !== currentStation.name || | ||||
|             Math.abs(originalStation.latitude - currentStation.latitude) > | ||||
|               COORDINATE_PRECISION_TOLERANCE || | ||||
|             Math.abs(originalStation.longitude - currentStation.longitude) > | ||||
|               COORDINATE_PRECISION_TOLERANCE | ||||
|           ) { | ||||
|             updatedStations.push({ id: numericId, ...currentStation }); | ||||
|           } | ||||
|         } else if (featureType === "route") { | ||||
|           const originalRoute = this.routes.find((r) => r.id === numericId); | ||||
|           if (!originalRoute) continue; | ||||
|  | ||||
|           const currentRoute = { | ||||
|             route_number: properties.name || "", | ||||
|             path: geometry.coordinates, | ||||
|           }; | ||||
|  | ||||
|           // ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском | ||||
|           if ( | ||||
|             originalRoute.route_number !== currentRoute.route_number || | ||||
|             !arePathsEqual(originalRoute.path, currentRoute.path) | ||||
|           ) { | ||||
|             updatedRoutes.push({ id: numericId, ...currentRoute }); | ||||
|           } | ||||
|         } else if (featureType === "sight") { | ||||
|           const originalSight = this.sights.find((s) => s.id === numericId); | ||||
|           if (!originalSight) continue; | ||||
|  | ||||
|           const currentSight = { | ||||
|             name: properties.name || "", | ||||
|             description: properties.description || "", | ||||
|             latitude: geometry.coordinates[1], | ||||
|             longitude: geometry.coordinates[0], | ||||
|           }; | ||||
|  | ||||
|           // ИЗМЕНЕНИЕ: Сравнение координат с допуском | ||||
|           if ( | ||||
|             originalSight.name !== currentSight.name || | ||||
|             originalSight.description !== currentSight.description || | ||||
|             Math.abs(originalSight.latitude - currentSight.latitude) > | ||||
|               COORDINATE_PRECISION_TOLERANCE || | ||||
|             Math.abs(originalSight.longitude - currentSight.longitude) > | ||||
|               COORDINATE_PRECISION_TOLERANCE | ||||
|           ) { | ||||
|             updatedSights.push({ id: numericId, ...currentSight }); | ||||
|           } | ||||
|         } | ||||
|       } else { | ||||
|         if (properties.featureType === "station") { | ||||
|           stations.push({ | ||||
|           newStations.push({ | ||||
|             name: properties.name || "", | ||||
|             latitude: geometry.coordinates[1], | ||||
|             longitude: geometry.coordinates[0], | ||||
|           }); | ||||
|         } else if (properties.featureType === "route") { | ||||
|           routes.push({ | ||||
|           newRoutes.push({ | ||||
|             route_number: properties.name || "", | ||||
|             path: geometry.coordinates, | ||||
|           }); | ||||
|         } else if (properties.featureType === "sight") { | ||||
|           sights.push({ | ||||
|           newSights.push({ | ||||
|             name: properties.name || "", | ||||
|             description: properties.description || "", | ||||
|             latitude: geometry.coordinates[1], | ||||
| @@ -126,16 +206,45 @@ class MapStore { | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|     } | ||||
|  | ||||
|     for (const station of stations) { | ||||
|       await languageInstance("ru").post("/station", station); | ||||
|     const requests: Promise<any>[] = []; | ||||
|  | ||||
|     newStations.forEach((data) => | ||||
|       requests.push(languageInstance("ru").post("/station", data)) | ||||
|     ); | ||||
|     newRoutes.forEach((data) => | ||||
|       requests.push(languageInstance("ru").post("/route", data)) | ||||
|     ); | ||||
|     newSights.forEach((data) => | ||||
|       requests.push(languageInstance("ru").post("/sight", data)) | ||||
|     ); | ||||
|  | ||||
|     updatedStations.forEach(({ id, ...data }) => | ||||
|       requests.push(languageInstance("ru").patch(`/station/${id}`, data)) | ||||
|     ); | ||||
|     updatedRoutes.forEach(({ id, ...data }) => | ||||
|       requests.push(languageInstance("ru").patch(`/route/${id}`, data)) | ||||
|     ); | ||||
|     updatedSights.forEach(({ id, ...data }) => | ||||
|       requests.push(languageInstance("ru").patch(`/sight/${id}`, data)) | ||||
|     ); | ||||
|  | ||||
|     if (requests.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|     for (const route of routes) { | ||||
|       await languageInstance("ru").post("/route", route); | ||||
|     } | ||||
|     for (const sight of sights) { | ||||
|       await languageInstance("ru").post("/sight", sight); | ||||
|  | ||||
|     try { | ||||
|       await Promise.all(requests); | ||||
|  | ||||
|       await Promise.all([ | ||||
|         this.getRoutes(), | ||||
|         this.getStations(), | ||||
|         this.getSights(), | ||||
|       ]); | ||||
|     } catch (error) { | ||||
|       console.error("Ошибка при сохранении данных:", error); | ||||
|       throw error; | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -16,7 +16,12 @@ import { | ||||
|   Snackbar, | ||||
| } from "@mui/material"; | ||||
| import { Save, ArrowLeft } from "lucide-react"; | ||||
| import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared"; | ||||
| import { | ||||
|   authInstance, | ||||
|   mediaStore, | ||||
|   MEDIA_TYPE_LABELS, | ||||
|   languageStore, | ||||
| } from "@shared"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
|  | ||||
| export const MediaEditPage = observer(() => { | ||||
| @@ -40,10 +45,10 @@ export const MediaEditPage = observer(() => { | ||||
|     if (id) { | ||||
|       mediaStore.getOneMedia(id); | ||||
|     } | ||||
|     console.log(newFile); | ||||
|     console.log(uploadDialogOpen); | ||||
|   }, [id]); | ||||
|  | ||||
|   useEffect(() => {}, [newFile, uploadDialogOpen]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (media) { | ||||
|       setMediaName(media.media_name); | ||||
| @@ -55,7 +60,11 @@ export const MediaEditPage = observer(() => { | ||||
|       if (extension) { | ||||
|         if (["glb", "gltf"].includes(extension)) { | ||||
|           setAvailableMediaTypes([6]); // 3D model | ||||
|         } else if (["jpg", "jpeg", "png", "gif"].includes(extension)) { | ||||
|         } else if ( | ||||
|           ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( | ||||
|             extension | ||||
|           ) | ||||
|         ) { | ||||
|           setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama | ||||
|         } else if (["mp4", "webm", "mov"].includes(extension)) { | ||||
|           setAvailableMediaTypes([2]); // Video | ||||
| @@ -64,6 +73,11 @@ export const MediaEditPage = observer(() => { | ||||
|     } | ||||
|   }, [media]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   // const handleDrop = (e: DragEvent<HTMLDivElement>) => { | ||||
|   //   e.preventDefault(); | ||||
|   //   e.stopPropagation(); | ||||
| @@ -99,7 +113,11 @@ export const MediaEditPage = observer(() => { | ||||
|         if (["glb", "gltf"].includes(extension)) { | ||||
|           setAvailableMediaTypes([6]); // 3D model | ||||
|           setMediaType(6); | ||||
|         } else if (["jpg", "jpeg", "png", "gif"].includes(extension)) { | ||||
|         } else if ( | ||||
|           ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( | ||||
|             extension | ||||
|           ) | ||||
|         ) { | ||||
|           setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama | ||||
|           setMediaType(1); // Default to Photo | ||||
|         } else if (["mp4", "webm", "mov"].includes(extension)) { | ||||
|   | ||||
| @@ -1,20 +1,30 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Trash2 } from "lucide-react"; | ||||
| import { Eye, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { DeleteModal } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const MediaListPage = observer(() => { | ||||
|   const { media, getMedia, deleteMedia } = mediaStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const [ids, setIds] = useState<string[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getMedia(); | ||||
|     const fetchMedia = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getMedia(); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchMedia(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -22,6 +32,17 @@ export const MediaListPage = observer(() => { | ||||
|       field: "media_name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "media_type", | ||||
| @@ -30,13 +51,15 @@ export const MediaListPage = observer(() => { | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <p> | ||||
|             { | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               MEDIA_TYPE_LABELS[ | ||||
|                 params.row.media_type as keyof typeof MEDIA_TYPE_LABELS | ||||
|               ] | ||||
|             } | ||||
|           </p> | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
| @@ -46,6 +69,7 @@ export const MediaListPage = observer(() => { | ||||
|       width: 200, | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -76,15 +100,37 @@ export const MediaListPage = observer(() => { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="w-full"> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Медиа</h1> | ||||
|           <CreateButton label="Создать медиа" path="/media/create" /> | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as string[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -103,6 +149,19 @@ export const MediaListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteMedia(id))); | ||||
|           getMedia(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -15,30 +15,32 @@ export const MediaPreviewPage = observer(() => { | ||||
|   }, []); | ||||
|  | ||||
|   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 className="w-full flex flex-col justify-center items-center gap-4"> | ||||
|       <div className="w-full flex flex-col justify-center items-center gap-4"> | ||||
|         <div className="flex justify-center items-center max-w-[60%]"> | ||||
|           <MediaViewer media={oneMedia!} /> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|         {oneMedia && ( | ||||
|           <div className="flex-1 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> | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										608
									
								
								src/pages/Route/LinekedStations.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										608
									
								
								src/pages/Route/LinekedStations.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,608 @@ | ||||
| import { useState, useEffect } from "react"; | ||||
| import { | ||||
|   Stack, | ||||
|   Typography, | ||||
|   Button, | ||||
|   FormControl, | ||||
|   Accordion, | ||||
|   AccordionSummary, | ||||
|   AccordionDetails, | ||||
|   useTheme, | ||||
|   TextField, | ||||
|   Autocomplete, | ||||
|   TableCell, | ||||
|   TableContainer, | ||||
|   Table, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   Paper, | ||||
|   TableBody, | ||||
|   IconButton, | ||||
|   Checkbox, | ||||
|   FormControlLabel, | ||||
|   Tabs, | ||||
|   Tab, | ||||
|   Box, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
| import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; | ||||
| import { | ||||
|   DragDropContext, | ||||
|   Droppable, | ||||
|   Draggable, | ||||
|   DropResult, | ||||
| } from "@hello-pangea/dnd"; | ||||
|  | ||||
| import { | ||||
|   authInstance, | ||||
|   languageStore, | ||||
|   routeStore, | ||||
|   selectedCityStore, | ||||
| } from "@shared"; | ||||
| import { EditStationModal } from "../../widgets/modals/EditStationModal"; | ||||
|  | ||||
| // Helper function to insert an item at a specific position (1-based index) | ||||
| function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] { | ||||
|   const index = pos - 1; | ||||
|   const result = [...arr]; | ||||
|   if (index >= result.length) { | ||||
|     result.push(value); | ||||
|   } else { | ||||
|     result.splice(index, 0, value); | ||||
|   } | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| // Helper function to reorder items after drag and drop | ||||
| const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => { | ||||
|   const result = Array.from(list); | ||||
|   const [removed] = result.splice(startIndex, 1); | ||||
|   result.splice(endIndex, 0, removed); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| type Field<T> = { | ||||
|   label: string; | ||||
|   data: keyof T; | ||||
|   render?: (value: any) => React.ReactNode; | ||||
| }; | ||||
|  | ||||
| type LinkedItemsProps<T> = { | ||||
|   parentId: string | number; | ||||
|   fields: Field<T>[]; | ||||
|   setItemsParent?: (items: T[]) => void; | ||||
|   type: "show" | "edit"; | ||||
|   dragAllowed?: boolean; | ||||
|   onUpdate?: () => void; | ||||
|   dontRecurse?: boolean; | ||||
|   disableCreation?: boolean; | ||||
|   updatedLinkedItems?: T[]; | ||||
|   refresh?: number; | ||||
|   routeDirection?: boolean; | ||||
| }; | ||||
|  | ||||
| export const LinkedItems = < | ||||
|   T extends { id: number; name: string; [key: string]: any } | ||||
| >( | ||||
|   props: LinkedItemsProps<T> | ||||
| ) => { | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Accordion sx={{ width: "100%" }}> | ||||
|         <AccordionSummary | ||||
|           expandIcon={<ExpandMoreIcon />} | ||||
|           sx={{ | ||||
|             background: theme.palette.background.paper, | ||||
|             borderBottom: `1px solid ${theme.palette.divider}`, | ||||
|             width: "100%", | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="subtitle1" fontWeight="bold"> | ||||
|             Привязанные станции | ||||
|           </Typography> | ||||
|         </AccordionSummary> | ||||
|  | ||||
|         <AccordionDetails | ||||
|           sx={{ background: theme.palette.background.paper, width: "100%" }} | ||||
|         > | ||||
|           <Stack gap={2} width="100%"> | ||||
|             <LinkedItemsContents {...props} /> | ||||
|           </Stack> | ||||
|         </AccordionDetails> | ||||
|       </Accordion> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const LinkedItemsContentsInner = < | ||||
|   T extends { id: number; name: string; [key: string]: any } | ||||
| >({ | ||||
|   parentId, | ||||
|   setItemsParent, | ||||
|   fields, | ||||
|   dragAllowed = false, | ||||
|   type, | ||||
|   onUpdate, | ||||
|   disableCreation = false, | ||||
|   updatedLinkedItems, | ||||
|   refresh, | ||||
|   routeDirection, | ||||
| }: LinkedItemsProps<T>) => { | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   const [position, setPosition] = useState<number>(1); | ||||
|   const [allItems, setAllItems] = useState<T[]>([]); | ||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]); | ||||
|   const [selectedItemId, setSelectedItemId] = useState<number | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState<boolean>(true); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [isModalOpen, setIsModalOpen] = useState(false); | ||||
|   const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set()); | ||||
|   const [activeTab, setActiveTab] = useState(0); | ||||
|   const [searchQuery, setSearchQuery] = useState(""); | ||||
|  | ||||
|   useEffect(() => {}, [error]); | ||||
|  | ||||
|   const parentResource = "route"; | ||||
|   const childResource = "station"; | ||||
|  | ||||
|   const availableItems = allItems | ||||
|     .filter((item) => !linkedItems.some((linked) => linked.id === item.id)) | ||||
|     .filter((item) => { | ||||
|       // Если направление маршрута не указано, показываем все станции | ||||
|       if (routeDirection === undefined) return true; | ||||
|       // Фильтруем станции по направлению маршрута | ||||
|       return item.direction === routeDirection; | ||||
|     }) | ||||
|     .filter((item) => { | ||||
|       // Фильтруем по городу из навбара | ||||
|       const selectedCityId = selectedCityStore.selectedCityId; | ||||
|       if (selectedCityId && "city_id" in item) { | ||||
|         return item.city_id === selectedCityId; | ||||
|       } | ||||
|       return true; | ||||
|     }) | ||||
|     .sort((a, b) => a.name.localeCompare(b.name)); | ||||
|  | ||||
|   // Фильтрация по поиску для массового режима | ||||
|   const filteredAvailableItems = availableItems.filter((item) => { | ||||
|     if (!searchQuery.trim()) return true; | ||||
|     return String(item.name).toLowerCase().includes(searchQuery.toLowerCase()); | ||||
|   }); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (updatedLinkedItems) { | ||||
|       setLinkedItems(updatedLinkedItems); | ||||
|     } | ||||
|   }, [updatedLinkedItems]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setItemsParent?.(linkedItems); | ||||
|   }, [linkedItems, setItemsParent]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setPosition(linkedItems.length + 1); | ||||
|   }, [linkedItems.length]); | ||||
|  | ||||
|   const onDragEnd = (result: DropResult) => { | ||||
|     if (!result.destination) return; | ||||
|  | ||||
|     const reorderedItems = reorder( | ||||
|       linkedItems, | ||||
|       result.source.index, | ||||
|       result.destination.index | ||||
|     ); | ||||
|  | ||||
|     setLinkedItems(reorderedItems); | ||||
|  | ||||
|     authInstance | ||||
|       .post(`/${parentResource}/${parentId}/${childResource}`, { | ||||
|         stations: reorderedItems.map((item) => ({ id: item.id })), | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error("Error updating station order:", error); | ||||
|         setError("Failed to update station order"); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (parentId) { | ||||
|       setIsLoading(true); | ||||
|       setError(null); | ||||
|       authInstance | ||||
|         .get(`/${parentResource}/${parentId}/${childResource}`) | ||||
|         .then((response) => { | ||||
|           setLinkedItems(response?.data || []); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error("Error fetching linked items:", error); | ||||
|           setError("Failed to load linked stations"); | ||||
|           setLinkedItems([]); | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           setIsLoading(false); | ||||
|         }); | ||||
|     } | ||||
|   }, [parentId, language, refresh]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (type === "edit") { | ||||
|       setError(null); | ||||
|       authInstance | ||||
|         .get(`/${childResource}`) | ||||
|         .then((response) => { | ||||
|           setAllItems(response?.data || []); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error("Error fetching all items:", error); | ||||
|           setError("Failed to load available stations"); | ||||
|           setAllItems([]); | ||||
|         }); | ||||
|     } | ||||
|   }, [type]); | ||||
|  | ||||
|   const linkItem = () => { | ||||
|     if (selectedItemId !== null) { | ||||
|       setError(null); | ||||
|       const requestData = { | ||||
|         stations: insertAtPosition( | ||||
|           linkedItems.map((item) => ({ id: item.id })), | ||||
|           position, | ||||
|           { id: selectedItemId } | ||||
|         ), | ||||
|       }; | ||||
|  | ||||
|       authInstance | ||||
|         .post(`/${parentResource}/${parentId}/${childResource}`, requestData) | ||||
|         .then(() => { | ||||
|           const newItem = allItems.find((item) => item.id === selectedItemId); | ||||
|           if (newItem) { | ||||
|             const updatedList = insertAtPosition( | ||||
|               [...linkedItems], | ||||
|               position, | ||||
|               newItem | ||||
|             ); | ||||
|             setLinkedItems(updatedList); | ||||
|           } | ||||
|           setSelectedItemId(null); | ||||
|           onUpdate?.(); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error("Error linking item:", error); | ||||
|           setError("Failed to link station"); | ||||
|         }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deleteItem = (itemId: number) => { | ||||
|     setError(null); | ||||
|     authInstance | ||||
|       .delete(`/${parentResource}/${parentId}/${childResource}`, { | ||||
|         data: { [`${childResource}_id`]: itemId }, | ||||
|       }) | ||||
|       .then(() => { | ||||
|         setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); | ||||
|         onUpdate?.(); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error("Error deleting item:", error); | ||||
|         setError("Failed to delete station"); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const handleStationClick = (item: T) => { | ||||
|     routeStore.setSelectedStationId(item.id); | ||||
|     setIsModalOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleCloseModal = () => { | ||||
|     setIsModalOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleCheckboxChange = (itemId: number) => { | ||||
|     const newSelected = new Set(selectedItems); | ||||
|     if (newSelected.has(itemId)) { | ||||
|       newSelected.delete(itemId); | ||||
|     } else { | ||||
|       newSelected.add(itemId); | ||||
|     } | ||||
|     setSelectedItems(newSelected); | ||||
|   }; | ||||
|  | ||||
|   const handleBulkLink = () => { | ||||
|     if (selectedItems.size === 0) return; | ||||
|  | ||||
|     setError(null); | ||||
|     const selectedStations = Array.from(selectedItems).map((id) => ({ id })); | ||||
|     const requestData = { | ||||
|       stations: [ | ||||
|         ...linkedItems.map((item) => ({ id: item.id })), | ||||
|         ...selectedStations, | ||||
|       ], | ||||
|     }; | ||||
|  | ||||
|     authInstance | ||||
|       .post(`/${parentResource}/${parentId}/${childResource}`, requestData) | ||||
|       .then(() => { | ||||
|         const newItems = allItems.filter((item) => selectedItems.has(item.id)); | ||||
|         setLinkedItems([...linkedItems, ...newItems]); | ||||
|         setSelectedItems(new Set()); | ||||
|         onUpdate?.(); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error("Error linking items:", error); | ||||
|         setError("Failed to link stations"); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {linkedItems?.length > 0 && ( | ||||
|         <DragDropContext onDragEnd={onDragEnd}> | ||||
|           <TableContainer component={Paper} sx={{ width: "100%" }}> | ||||
|             <Table sx={{ width: "100%" }}> | ||||
|               <TableHead> | ||||
|                 <TableRow> | ||||
|                   {type === "edit" && dragAllowed && ( | ||||
|                     <TableCell width="40px"></TableCell> | ||||
|                   )} | ||||
|                   <TableCell key="id" width="60px"> | ||||
|                     № | ||||
|                   </TableCell> | ||||
|                   {fields.map((field) => ( | ||||
|                     <TableCell key={String(field.data)}> | ||||
|                       {field.label} | ||||
|                     </TableCell> | ||||
|                   ))} | ||||
|                   {type === "edit" && ( | ||||
|                     <TableCell width="120px">Действие</TableCell> | ||||
|                   )} | ||||
|                 </TableRow> | ||||
|               </TableHead> | ||||
|  | ||||
|               <Droppable | ||||
|                 droppableId="droppable-stations" | ||||
|                 isDropDisabled={type !== "edit" || !dragAllowed} | ||||
|               > | ||||
|                 {(provided) => ( | ||||
|                   <TableBody | ||||
|                     ref={provided.innerRef} | ||||
|                     {...provided.droppableProps} | ||||
|                   > | ||||
|                     {linkedItems.map((item, index) => ( | ||||
|                       <Draggable | ||||
|                         key={item.id} | ||||
|                         draggableId={"station-" + String(item.id)} | ||||
|                         index={index} | ||||
|                         isDragDisabled={type !== "edit" || !dragAllowed} | ||||
|                       > | ||||
|                         {(provided) => ( | ||||
|                           <TableRow | ||||
|                             sx={{ cursor: "pointer" }} | ||||
|                             ref={provided.innerRef} | ||||
|                             {...provided.draggableProps} | ||||
|                             hover | ||||
|                             onClick={() => handleStationClick(item)} | ||||
|                           > | ||||
|                             {type === "edit" && dragAllowed && ( | ||||
|                               <TableCell {...provided.dragHandleProps}> | ||||
|                                 <IconButton size="small"> | ||||
|                                   <DragIndicatorIcon /> | ||||
|                                 </IconButton> | ||||
|                               </TableCell> | ||||
|                             )} | ||||
|                             <TableCell>{index + 1}</TableCell> | ||||
|                             {fields.map((field, idx) => ( | ||||
|                               <TableCell key={String(field.data) + String(idx)}> | ||||
|                                 {field.render | ||||
|                                   ? field.render(item[field.data]) | ||||
|                                   : item[field.data]} | ||||
|                               </TableCell> | ||||
|                             ))} | ||||
|                             {type === "edit" && ( | ||||
|                               <TableCell> | ||||
|                                 <Button | ||||
|                                   variant="outlined" | ||||
|                                   color="error" | ||||
|                                   size="small" | ||||
|                                   onClick={(e) => { | ||||
|                                     e.stopPropagation(); | ||||
|                                     deleteItem(item.id); | ||||
|                                   }} | ||||
|                                 > | ||||
|                                   Отвязать | ||||
|                                 </Button> | ||||
|                               </TableCell> | ||||
|                             )} | ||||
|                           </TableRow> | ||||
|                         )} | ||||
|                       </Draggable> | ||||
|                     ))} | ||||
|                     {provided.placeholder} | ||||
|                   </TableBody> | ||||
|                 )} | ||||
|               </Droppable> | ||||
|             </Table> | ||||
|           </TableContainer> | ||||
|         </DragDropContext> | ||||
|       )} | ||||
|  | ||||
|       {linkedItems.length === 0 && !isLoading && ( | ||||
|         <Typography color="textSecondary" textAlign="center" py={2}> | ||||
|           Станции не найдены | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       {type === "edit" && !disableCreation && ( | ||||
|         <Stack gap={2} mt={2}> | ||||
|           <Typography variant="subtitle1">Добавить остановки</Typography> | ||||
|           {routeDirection !== undefined && ( | ||||
|             <Typography variant="body2" color="textSecondary"> | ||||
|               Показываются только остановки для{" "} | ||||
|               {routeDirection ? "прямого" : "обратного"} направления | ||||
|             </Typography> | ||||
|           )} | ||||
|  | ||||
|           <Tabs | ||||
|             value={activeTab} | ||||
|             onChange={(_, newValue) => setActiveTab(newValue)} | ||||
|           > | ||||
|             <Tab label="По одной" /> | ||||
|             <Tab label="Массово" /> | ||||
|           </Tabs> | ||||
|  | ||||
|           <Box sx={{ mt: 2 }}> | ||||
|             {activeTab === 0 && ( | ||||
|               <Stack gap={2}> | ||||
|                 <Autocomplete | ||||
|                   fullWidth | ||||
|                   value={ | ||||
|                     availableItems?.find( | ||||
|                       (item) => item.id === selectedItemId | ||||
|                     ) || null | ||||
|                   } | ||||
|                   onChange={(_, newValue) => | ||||
|                     setSelectedItemId(newValue?.id || null) | ||||
|                   } | ||||
|                   options={availableItems} | ||||
|                   getOptionLabel={(item) => String(item.name)} | ||||
|                   renderInput={(params) => ( | ||||
|                     <TextField | ||||
|                       {...params} | ||||
|                       label="Выберите остановку" | ||||
|                       fullWidth | ||||
|                     /> | ||||
|                   )} | ||||
|                   isOptionEqualToValue={(option, value) => | ||||
|                     option.id === value?.id | ||||
|                   } | ||||
|                   filterOptions={(options, { inputValue }) => { | ||||
|                     const searchWords = inputValue | ||||
|                       .toLowerCase() | ||||
|                       .split(" ") | ||||
|                       .filter(Boolean); | ||||
|                     return options.filter((option) => { | ||||
|                       const optionWords = String(option.name) | ||||
|                         .toLowerCase() | ||||
|                         .split(" "); | ||||
|                       return searchWords.every((searchWord) => | ||||
|                         optionWords.some((word) => word.startsWith(searchWord)) | ||||
|                       ); | ||||
|                     }); | ||||
|                   }} | ||||
|                   renderOption={(props, option) => ( | ||||
|                     <li {...props} key={option.id}> | ||||
|                       <div className="flex justify-between items-center w-full"> | ||||
|                         <p>{String(option.name)}</p> | ||||
|                         <p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate"> | ||||
|                           {String(option.description)} | ||||
|                         </p> | ||||
|                       </div> | ||||
|                     </li> | ||||
|                   )} | ||||
|                 /> | ||||
|  | ||||
|                 <FormControl fullWidth> | ||||
|                   <TextField | ||||
|                     type="number" | ||||
|                     label="Позиция добавляемой остановки" | ||||
|                     value={position} | ||||
|                     onChange={(e) => { | ||||
|                       const newValue = Math.max(1, Number(e.target.value)); | ||||
|                       setPosition( | ||||
|                         newValue > linkedItems.length + 1 | ||||
|                           ? linkedItems.length + 1 | ||||
|                           : newValue | ||||
|                       ); | ||||
|                     }} | ||||
|                     InputProps={{ | ||||
|                       inputProps: { min: 1, max: linkedItems.length + 1 }, | ||||
|                     }} | ||||
|                     fullWidth | ||||
|                   /> | ||||
|                 </FormControl> | ||||
|  | ||||
|                 <Button | ||||
|                   variant="contained" | ||||
|                   onClick={linkItem} | ||||
|                   disabled={!selectedItemId} | ||||
|                   sx={{ alignSelf: "flex-start" }} | ||||
|                 > | ||||
|                   Добавить | ||||
|                 </Button> | ||||
|               </Stack> | ||||
|             )} | ||||
|  | ||||
|             {activeTab === 1 && ( | ||||
|               <Stack gap={2}> | ||||
|                 {/* Поле поиска */} | ||||
|                 <TextField | ||||
|                   fullWidth | ||||
|                   label="Поиск остановок" | ||||
|                   value={searchQuery} | ||||
|                   onChange={(e) => setSearchQuery(e.target.value)} | ||||
|                   placeholder="Введите название остановки..." | ||||
|                   size="small" | ||||
|                   sx={{ mb: 1 }} | ||||
|                 /> | ||||
|  | ||||
|                 {/* Список доступных остановок с чекбоксами */} | ||||
|                 <Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}> | ||||
|                   <Stack gap={1}> | ||||
|                     {filteredAvailableItems.map((item) => ( | ||||
|                       <FormControlLabel | ||||
|                         key={item.id} | ||||
|                         control={ | ||||
|                           <Checkbox | ||||
|                             checked={selectedItems.has(item.id)} | ||||
|                             onChange={() => handleCheckboxChange(item.id)} | ||||
|                             size="small" | ||||
|                           /> | ||||
|                         } | ||||
|                         label={String(item.name)} | ||||
|                         sx={{ | ||||
|                           margin: 0, | ||||
|                           "& .MuiFormControlLabel-label": { | ||||
|                             fontSize: "0.875rem", | ||||
|                           }, | ||||
|                         }} | ||||
|                       /> | ||||
|                     ))} | ||||
|                     {filteredAvailableItems.length === 0 && ( | ||||
|                       <Typography | ||||
|                         color="textSecondary" | ||||
|                         textAlign="center" | ||||
|                         py={1} | ||||
|                       > | ||||
|                         {searchQuery.trim() | ||||
|                           ? "Остановки не найдены" | ||||
|                           : "Нет доступных остановок"} | ||||
|                       </Typography> | ||||
|                     )} | ||||
|                   </Stack> | ||||
|                 </Paper> | ||||
|  | ||||
|                 <Button | ||||
|                   variant="contained" | ||||
|                   onClick={handleBulkLink} | ||||
|                   disabled={selectedItems.size === 0} | ||||
|                   sx={{ alignSelf: "flex-start" }} | ||||
|                 > | ||||
|                   Добавить выбранные ({selectedItems.size}) | ||||
|                 </Button> | ||||
|               </Stack> | ||||
|             )} | ||||
|           </Box> | ||||
|         </Stack> | ||||
|       )} | ||||
|       <EditStationModal open={isModalOpen} onClose={handleCloseModal} /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const LinkedItemsContents = observer( | ||||
|   LinkedItemsContentsInner | ||||
| ) as typeof LinkedItemsContentsInner; | ||||
| @@ -8,16 +8,26 @@ import { | ||||
|   InputLabel, | ||||
|   Typography, | ||||
|   Box, | ||||
|   Dialog, | ||||
|   DialogTitle, | ||||
|   DialogContent, | ||||
|   DialogActions, | ||||
| } from "@mui/material"; | ||||
|  | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Loader2, Save } from "lucide-react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { ArrowLeft, Loader2, Save, Plus } from "lucide-react"; | ||||
| import { useEffect, useState, useMemo } from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { carrierStore } from "../../../shared/store/CarrierStore"; | ||||
| import { articlesStore } from "../../../shared/store/ArticlesStore"; | ||||
| import { Route, routeStore } from "../../../shared/store/RouteStore"; | ||||
| import { | ||||
|   languageStore, | ||||
|   SelectArticleModal, | ||||
|   SelectMediaDialog, | ||||
|   selectedCityStore, | ||||
| } from "@shared"; | ||||
|  | ||||
| export const RouteCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
| @@ -32,12 +42,89 @@ export const RouteCreatePage = observer(() => { | ||||
|   const [turn, setTurn] = useState(""); | ||||
|   const [centerLat, setCenterLat] = useState(""); | ||||
|   const [centerLng, setCenterLng] = useState(""); | ||||
|   const [videoPreview, setVideoPreview] = useState<string>(""); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = | ||||
|     useState(false); | ||||
|   const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); | ||||
|   const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     carrierStore.getCarriers(); | ||||
|     carrierStore.getCarriers(language); | ||||
|     articlesStore.getArticleList(); | ||||
|   }, []); | ||||
|   }, [language]); | ||||
|  | ||||
|   // Фильтруем перевозчиков только из выбранного города | ||||
|   const filteredCarriers = useMemo(() => { | ||||
|     const carriers = | ||||
|       carrierStore.carriers[language as keyof typeof carrierStore.carriers] | ||||
|         .data || []; | ||||
|  | ||||
|     if (!selectedCityStore.selectedCityId) { | ||||
|       return carriers; | ||||
|     } | ||||
|  | ||||
|     return carriers.filter( | ||||
|       (carrier: any) => carrier.city_id === selectedCityStore.selectedCityId | ||||
|     ); | ||||
|   }, [carrierStore.carriers, language, selectedCityStore.selectedCityId]); | ||||
|  | ||||
|   const validateCoordinates = (value: string) => { | ||||
|     try { | ||||
|       const lines = value.trim().split("\n"); | ||||
|       const coordinates = lines.map((line) => { | ||||
|         const [lat, lon] = line | ||||
|           .trim() | ||||
|           .split(/[\s,]+/) | ||||
|           .map(Number); | ||||
|         return [lat, lon]; | ||||
|       }); | ||||
|  | ||||
|       if (coordinates.length === 0) { | ||||
|         return "Введите хотя бы одну пару координат"; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         !coordinates.every( | ||||
|           (point) => Array.isArray(point) && point.length === 2 | ||||
|         ) | ||||
|       ) { | ||||
|         return "Каждая строка должна содержать две координаты"; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         !coordinates.every((point) => | ||||
|           point.every((coord) => !isNaN(coord) && typeof coord === "number") | ||||
|         ) | ||||
|       ) { | ||||
|         return "Координаты должны быть числами"; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch { | ||||
|       return "Неверный формат координат"; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleArticleSelect = (articleId: number) => { | ||||
|     setGovernorAppeal(articleId.toString()); | ||||
|     setIsSelectArticleDialogOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleVideoSelect = (media: { | ||||
|     id: string; | ||||
|     filename: string; | ||||
|     media_name?: string; | ||||
|     media_type: number; | ||||
|   }) => { | ||||
|     setVideoPreview(media.id); | ||||
|     setIsSelectVideoDialogOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleVideoPreviewClick = () => { | ||||
|     setIsVideoPreviewOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleCreateRoute = async () => { | ||||
|     try { | ||||
| @@ -51,22 +138,31 @@ export const RouteCreatePage = observer(() => { | ||||
|       const center_latitude = centerLat ? Number(centerLat) : undefined; | ||||
|       const center_longitude = centerLng ? Number(centerLng) : undefined; | ||||
|       const route_direction = direction === "forward"; | ||||
|  | ||||
|       const validationResult = validateCoordinates(routeCoords); | ||||
|       if (validationResult !== true) { | ||||
|         toast.error(validationResult); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Координаты маршрута как массив массивов чисел | ||||
|       const path = routeCoords | ||||
|         .trim() | ||||
|         .split("\n") | ||||
|         .map((line) => | ||||
|           line | ||||
|             .split(" ") | ||||
|             .map((coord) => Number(coord.trim())) | ||||
|             .filter((n) => !isNaN(n)) | ||||
|         ) | ||||
|         .filter((arr) => arr.length === 2); | ||||
|         .map((line) => { | ||||
|           const [lat, lon] = line | ||||
|             .trim() | ||||
|             .split(/[\s,]+/) | ||||
|             .map(Number); | ||||
|           return [lat, lon]; | ||||
|         }); | ||||
|  | ||||
|       // Собираем объект маршрута | ||||
|       const newRoute: Partial<Route> = { | ||||
|         carrier: | ||||
|           carrierStore.carriers.data.find((c: any) => c.id === carrier_id) | ||||
|             ?.full_name || "", | ||||
|           carrierStore.carriers[ | ||||
|             language as keyof typeof carrierStore.carriers | ||||
|           ].data?.find((c: any) => c.id === carrier_id)?.full_name || "", | ||||
|         carrier_id, | ||||
|         route_number: routeNumber, | ||||
|         route_sys_number: govRouteNumber, | ||||
| @@ -78,6 +174,8 @@ export const RouteCreatePage = observer(() => { | ||||
|         center_latitude, | ||||
|         center_longitude, | ||||
|         path, | ||||
|         video_preview: | ||||
|           videoPreview && videoPreview !== "" ? videoPreview : undefined, | ||||
|       }; | ||||
|  | ||||
|       await routeStore.createRoute(newRoute); | ||||
| @@ -91,137 +189,300 @@ export const RouteCreatePage = observer(() => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // Получаем название выбранной статьи для отображения | ||||
|   const selectedArticle = articlesStore.articleList.ru.data.find( | ||||
|     (article) => article.id === Number(governorAppeal) | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Маршруты / Создать | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <Typography variant="h5" fontWeight={700}> | ||||
|         Создать маршрут | ||||
|       </Typography> | ||||
|       <Box className="flex flex-col gap-6 w-full"> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Выберите перевозчика</InputLabel> | ||||
|           <Select | ||||
|             value={carrier} | ||||
|             label="Выберите перевозчика" | ||||
|             onChange={(e) => setCarrier(e.target.value as string)} | ||||
|             disabled={carrierStore.carriers.data.length === 0} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {carrierStore.carriers.data.map( | ||||
|               (c: (typeof carrierStore.carriers.data)[number]) => ( | ||||
|                 <MenuItem key={c.id} value={c.id}> | ||||
|                   {c.full_name} | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <Box className="flex flex-col gap-6 w-full"> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Выберите перевозчика</InputLabel> | ||||
|             <Select | ||||
|               value={carrier} | ||||
|               label="Выберите перевозчика" | ||||
|               onChange={(e) => setCarrier(e.target.value as string)} | ||||
|               disabled={filteredCarriers.length === 0} | ||||
|             > | ||||
|               <MenuItem value="">Не выбрано</MenuItem> | ||||
|               {filteredCarriers.map((carrier: any) => ( | ||||
|                 <MenuItem key={carrier.id} value={carrier.id}> | ||||
|                   {carrier.full_name} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута" | ||||
|           required | ||||
|           value={routeNumber} | ||||
|           onChange={(e) => setRouteNumber(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Координаты маршрута" | ||||
|           multiline | ||||
|           minRows={3} | ||||
|           value={routeCoords} | ||||
|           onChange={(e) => setRouteCoords(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута в Говорящем Городе" | ||||
|           required | ||||
|           value={govRouteNumber} | ||||
|           onChange={(e) => setGovRouteNumber(e.target.value)} | ||||
|         /> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Обращение губернатора</InputLabel> | ||||
|           <Select | ||||
|             value={governorAppeal} | ||||
|             label="Обращение губернатора" | ||||
|             onChange={(e) => setGovernorAppeal(e.target.value as string)} | ||||
|             disabled={articlesStore.articleList.ru.data.length === 0} | ||||
|               ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута" | ||||
|             required | ||||
|             value={routeNumber} | ||||
|             onChange={(e) => setRouteNumber(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Координаты маршрута" | ||||
|             multiline | ||||
|             minRows={2} | ||||
|             maxRows={10} | ||||
|             value={routeCoords} | ||||
|             onChange={(e) => { | ||||
|               const newValue = e.target.value; | ||||
|               setRouteCoords(newValue); | ||||
|             }} | ||||
|             onKeyDown={(e) => { | ||||
|               if (e.key === "Enter") { | ||||
|                 const lines = routeCoords.split("\n"); | ||||
|                 const lastLine = lines[lines.length - 1]; | ||||
|  | ||||
|                 // Если мы на последней строке и она не пустая | ||||
|                 if (lastLine && lastLine.trim()) { | ||||
|                   e.preventDefault(); | ||||
|                   const newValue = routeCoords + "\n"; | ||||
|                   setRouteCoords(newValue); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|             error={validateCoordinates(routeCoords) !== true} | ||||
|             helperText={ | ||||
|               typeof validateCoordinates(routeCoords) === "string" | ||||
|                 ? validateCoordinates(routeCoords) | ||||
|                 : "Формат: широта долгота" | ||||
|             } | ||||
|             placeholder="55.7558 37.6173
55.7539 37.6208" | ||||
|             sx={{ | ||||
|               "& .MuiInputBase-root": { | ||||
|                 maxHeight: "500px", | ||||
|                 overflow: "auto", | ||||
|               }, | ||||
|               "& .MuiInputBase-input": { | ||||
|                 fontFamily: "monospace", | ||||
|                 fontSize: "0.8rem", | ||||
|                 lineHeight: "1.2", | ||||
|                 padding: "8px 12px", | ||||
|               }, | ||||
|               "& .MuiFormHelperText-root": { | ||||
|                 fontSize: "0.75rem", | ||||
|                 marginTop: "2px", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута в Говорящем Городе" | ||||
|             required | ||||
|             value={govRouteNumber} | ||||
|             onChange={(e) => setGovRouteNumber(e.target.value)} | ||||
|           /> | ||||
|  | ||||
|           {/* Заменяем Select на кнопку для выбора статьи */} | ||||
|           <Box className="flex flex-col gap-2"> | ||||
|             <label className="text-sm font-medium text-gray-700"> | ||||
|               Обращение к пассажирам | ||||
|             </label> | ||||
|             <Box className="flex gap-2"> | ||||
|               <TextField | ||||
|                 className="flex-1" | ||||
|                 value={selectedArticle?.heading || "Статья не выбрана"} | ||||
|                 placeholder="Выберите статью" | ||||
|                 disabled | ||||
|                 sx={{ | ||||
|                   "& .MuiInputBase-input": { | ||||
|                     color: selectedArticle ? "inherit" : "#999", | ||||
|                   }, | ||||
|                 }} | ||||
|               /> | ||||
|               <Button | ||||
|                 variant="outlined" | ||||
|                 onClick={() => setIsSelectArticleDialogOpen(true)} | ||||
|                 startIcon={<Plus size={16} />} | ||||
|                 sx={{ minWidth: "auto", px: 2 }} | ||||
|               > | ||||
|                 Выбрать | ||||
|               </Button> | ||||
|             </Box> | ||||
|           </Box> | ||||
|  | ||||
|           {/* Селектор видеозаставки */} | ||||
|           <Box className="flex flex-col gap-2"> | ||||
|             <label className="text-sm font-medium text-gray-700"> | ||||
|               Видеозаставка | ||||
|             </label> | ||||
|             <Box className="flex gap-2"> | ||||
|               <Box | ||||
|                 className="flex-1" | ||||
|                 onClick={handleVideoPreviewClick} | ||||
|                 sx={{ | ||||
|                   cursor: | ||||
|                     videoPreview && videoPreview !== "" ? "pointer" : "default", | ||||
|                 }} | ||||
|               > | ||||
|                 <Box | ||||
|                   className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4" | ||||
|                   sx={{ | ||||
|                     "& .MuiInputBase-input": { | ||||
|                       color: | ||||
|                         videoPreview && videoPreview !== "" | ||||
|                           ? "inherit" | ||||
|                           : "#999", | ||||
|                       cursor: | ||||
|                         videoPreview && videoPreview !== "" | ||||
|                           ? "pointer" | ||||
|                           : "default", | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
|                   <Typography variant="body1" className="text-sm"> | ||||
|                     {videoPreview && videoPreview !== "" | ||||
|                       ? "Видео выбрано" | ||||
|                       : "Видео не выбрано"} | ||||
|                   </Typography> | ||||
|                   {videoPreview && videoPreview !== "" && ( | ||||
|                     <Box | ||||
|                       onClick={(e) => { | ||||
|                         e.stopPropagation(); | ||||
|                         setVideoPreview(""); | ||||
|                       }} | ||||
|                       sx={{ | ||||
|                         cursor: "pointer", | ||||
|                         color: "#999", | ||||
|                         "&:hover": { | ||||
|                           color: "#666", | ||||
|                         }, | ||||
|                       }} | ||||
|                     > | ||||
|                       <Typography variant="body1" className="text-lg font-bold"> | ||||
|                         × | ||||
|                       </Typography> | ||||
|                     </Box> | ||||
|                   )} | ||||
|                 </Box> | ||||
|               </Box> | ||||
|               <Button | ||||
|                 variant="outlined" | ||||
|                 onClick={() => setIsSelectVideoDialogOpen(true)} | ||||
|                 startIcon={<Plus size={16} />} | ||||
|                 sx={{ minWidth: "auto", px: 2 }} | ||||
|               > | ||||
|                 Выбрать | ||||
|               </Button> | ||||
|             </Box> | ||||
|           </Box> | ||||
|  | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|             <Select | ||||
|               value={direction} | ||||
|               label="Прямой/обратный маршрут" | ||||
|               onChange={(e) => setDirection(e.target.value)} | ||||
|             > | ||||
|               <MenuItem value="forward">Прямой</MenuItem> | ||||
|               <MenuItem value="backward">Обратный</MenuItem> | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (мин)" | ||||
|             value={scaleMin} | ||||
|             onChange={(e) => setScaleMin(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (макс)" | ||||
|             value={scaleMax} | ||||
|             onChange={(e) => setScaleMax(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Поворот" | ||||
|             value={turn} | ||||
|             onChange={(e) => setTurn(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. широта" | ||||
|             value={centerLat} | ||||
|             onChange={(e) => setCenterLat(e.target.value)} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. долгота" | ||||
|             value={centerLng} | ||||
|             onChange={(e) => setCenterLng(e.target.value)} | ||||
|           /> | ||||
|         </Box> | ||||
|         <div className="flex w-full justify-end"> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             className="w-min flex gap-2 items-center" | ||||
|             startIcon={<Save size={20} />} | ||||
|             onClick={handleCreateRoute} | ||||
|             disabled={isLoading} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {articlesStore.articleList.ru.data.map( | ||||
|               (a: (typeof articlesStore.articleList.ru.data)[number]) => ( | ||||
|                 <MenuItem key={a.id} value={a.id}> | ||||
|                   {a.heading} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             {isLoading ? ( | ||||
|               <Loader2 size={20} className="animate-spin" /> | ||||
|             ) : ( | ||||
|               "Сохранить" | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|           <Select | ||||
|             value={direction} | ||||
|             label="Прямой/обратный маршрут" | ||||
|             onChange={(e) => setDirection(e.target.value)} | ||||
|           > | ||||
|             <MenuItem value="forward">Прямой</MenuItem> | ||||
|             <MenuItem value="backward">Обратный</MenuItem> | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (мин)" | ||||
|           value={scaleMin} | ||||
|           onChange={(e) => setScaleMin(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (макс)" | ||||
|           value={scaleMax} | ||||
|           onChange={(e) => setScaleMax(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Поворот" | ||||
|           value={turn} | ||||
|           onChange={(e) => setTurn(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. широта" | ||||
|           value={centerLat} | ||||
|           onChange={(e) => setCenterLat(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. долгота" | ||||
|           value={centerLng} | ||||
|           onChange={(e) => setCenterLng(e.target.value)} | ||||
|         /> | ||||
|       </Box> | ||||
|       <div className="flex w-full justify-end"> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleCreateRoute} | ||||
|           disabled={isLoading} | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {/* Модальное окно выбора статьи */} | ||||
|       <SelectArticleModal | ||||
|         open={isSelectArticleDialogOpen} | ||||
|         onClose={() => setIsSelectArticleDialogOpen(false)} | ||||
|         onSelectArticle={handleArticleSelect} | ||||
|       /> | ||||
|  | ||||
|       {/* Модальное окно выбора видео */} | ||||
|       <SelectMediaDialog | ||||
|         open={isSelectVideoDialogOpen} | ||||
|         onClose={() => setIsSelectVideoDialogOpen(false)} | ||||
|         onSelectMedia={handleVideoSelect} | ||||
|         mediaType={2} | ||||
|       /> | ||||
|  | ||||
|       {/* Модальное окно предпросмотра видео */} | ||||
|       {videoPreview && videoPreview !== "" && ( | ||||
|         <Dialog | ||||
|           open={isVideoPreviewOpen} | ||||
|           onClose={() => setIsVideoPreviewOpen(false)} | ||||
|           maxWidth="md" | ||||
|           fullWidth | ||||
|         > | ||||
|           <DialogTitle>Предпросмотр видео</DialogTitle> | ||||
|           <DialogContent> | ||||
|             <Box className="flex justify-center items-center p-4"> | ||||
|               <MediaViewer | ||||
|                 media={{ | ||||
|                   id: videoPreview, | ||||
|                   media_type: 2, | ||||
|                   filename: "video_preview", | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           </DialogContent> | ||||
|           <DialogActions> | ||||
|             <Button onClick={() => setIsVideoPreviewOpen(false)}> | ||||
|               Закрыть | ||||
|             </Button> | ||||
|           </DialogActions> | ||||
|         </Dialog> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -8,33 +8,68 @@ import { | ||||
|   InputLabel, | ||||
|   Typography, | ||||
|   Box, | ||||
|   Dialog, | ||||
|   DialogTitle, | ||||
|   DialogContent, | ||||
|   DialogActions, | ||||
| } from "@mui/material"; | ||||
|  | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { ArrowLeft, Copy, Save, Plus } from "lucide-react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
|  | ||||
| import { carrierStore } from "../../../shared/store/CarrierStore"; | ||||
| import { articlesStore } from "../../../shared/store/ArticlesStore"; | ||||
| import { routeStore } from "../../../shared/store/RouteStore"; | ||||
| import { | ||||
|   routeStore, | ||||
|   languageStore, | ||||
|   SelectArticleModal, | ||||
|   SelectMediaDialog, | ||||
| } from "@shared"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { stationsStore } from "@shared"; | ||||
| import { LinkedItems } from "../LinekedStations"; | ||||
|  | ||||
| export const RouteEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const { id } = useParams(); | ||||
|   const { editRouteData } = routeStore; | ||||
|   const { editRouteData, copyRouteAction } = routeStore; | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = | ||||
|     useState(false); | ||||
|   const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); | ||||
|   const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|   const [coordinates, setCoordinates] = useState<string>(""); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       // Устанавливаем русский язык при загрузке страницы | ||||
|       const response = await routeStore.getRoute(Number(id)); | ||||
|       routeStore.setEditRouteData(response); | ||||
|       carrierStore.getCarriers(); | ||||
|       languageStore.setLanguage("ru"); | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       carrierStore.getCarriers(language); | ||||
|       stationsStore.getStations(); | ||||
|       articlesStore.getArticleList(); | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [id]); | ||||
|   }, [id, language]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (editRouteData.path && editRouteData.path.length > 0) { | ||||
|       const formattedPath = editRouteData.path | ||||
|         .map((coords) => coords.join(" ")) | ||||
|         .join("\n"); | ||||
|       setCoordinates(formattedPath); | ||||
|     } | ||||
|   }, [editRouteData.path]); | ||||
|  | ||||
|   const handleSave = async () => { | ||||
|     setIsLoading(true); | ||||
| @@ -43,183 +78,460 @@ export const RouteEditPage = observer(() => { | ||||
|     setIsLoading(false); | ||||
|   }; | ||||
|  | ||||
|   const validateCoordinates = (value: string) => { | ||||
|     try { | ||||
|       const lines = value.trim().split("\n"); | ||||
|       const coordinates = lines.map((line) => { | ||||
|         const [lat, lon] = line | ||||
|           .trim() | ||||
|           .split(/[\s,]+/) | ||||
|           .map(Number); | ||||
|         return [lat, lon]; | ||||
|       }); | ||||
|  | ||||
|       if (coordinates.length === 0) { | ||||
|         return "Введите хотя бы одну пару координат"; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         !coordinates.every( | ||||
|           (point) => Array.isArray(point) && point.length === 2 | ||||
|         ) | ||||
|       ) { | ||||
|         return "Каждая строка должна содержать две координаты"; | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         !coordinates.every((point) => | ||||
|           point.every((coord) => !isNaN(coord) && typeof coord === "number") | ||||
|         ) | ||||
|       ) { | ||||
|         return "Координаты должны быть числами"; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } catch { | ||||
|       return "Неверный формат координат"; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleCopy = async () => { | ||||
|     await copyRouteAction(Number(id)); | ||||
|     toast.success("Маршрут успешно скопирован"); | ||||
|   }; | ||||
|  | ||||
|   const handleArticleSelect = (articleId: number) => { | ||||
|     routeStore.setEditRouteData({ | ||||
|       governor_appeal: articleId, | ||||
|     }); | ||||
|     setIsSelectArticleDialogOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleVideoSelect = (media: { | ||||
|     id: string; | ||||
|     filename: string; | ||||
|     media_name?: string; | ||||
|     media_type: number; | ||||
|   }) => { | ||||
|     routeStore.setEditRouteData({ | ||||
|       video_preview: media.id, | ||||
|     }); | ||||
|     setIsSelectVideoDialogOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleVideoPreviewClick = () => { | ||||
|     if (editRouteData.video_preview && editRouteData.video_preview !== "") { | ||||
|       setIsVideoPreviewOpen(true); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // Получаем название выбранной статьи для отображения | ||||
|   const selectedArticle = articlesStore.articleList.ru.data.find( | ||||
|     (article) => article.id === editRouteData.governor_appeal | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Маршруты / Редактировать | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <Typography variant="h5" fontWeight={700}> | ||||
|         Редактировать маршрут | ||||
|       </Typography> | ||||
|       <Box className="flex flex-col gap-6 w-full"> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Выберите перевозчика</InputLabel> | ||||
|           <Select | ||||
|             value={editRouteData.carrier_id} | ||||
|             label="Выберите перевозчика" | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 carrier_id: Number(e.target.value), | ||||
|                 carrier: | ||||
|                   carrierStore.carriers.data.find( | ||||
|                     (c) => c.id === Number(e.target.value) | ||||
|                   )?.full_name || "", | ||||
|               }) | ||||
|             } | ||||
|             disabled={carrierStore.carriers.data.length === 0} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {carrierStore.carriers.data.map( | ||||
|               (c: (typeof carrierStore.carriers.data)[number]) => ( | ||||
|                 <MenuItem key={c.id} value={c.id}> | ||||
|                   {c.full_name} | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <Box className="flex flex-col gap-6 w-full"> | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Выберите перевозчика</InputLabel> | ||||
|             <Select | ||||
|               value={editRouteData.carrier_id} | ||||
|               label="Выберите перевозчика" | ||||
|               onChange={(e) => | ||||
|                 routeStore.setEditRouteData({ | ||||
|                   carrier_id: Number(e.target.value), | ||||
|                   carrier: | ||||
|                     carrierStore.carriers[ | ||||
|                       language as keyof typeof carrierStore.carriers | ||||
|                     ].data?.find((c) => c.id === Number(e.target.value)) | ||||
|                       ?.full_name || "", | ||||
|                 }) | ||||
|               } | ||||
|               disabled={ | ||||
|                 carrierStore.carriers[ | ||||
|                   language as keyof typeof carrierStore.carriers | ||||
|                 ].data?.length === 0 | ||||
|               } | ||||
|             > | ||||
|               <MenuItem value="">Не выбрано</MenuItem> | ||||
|               {carrierStore.carriers[ | ||||
|                 language as keyof typeof carrierStore.carriers | ||||
|               ].data?.map((carrier) => ( | ||||
|                 <MenuItem key={carrier.id} value={carrier.id}> | ||||
|                   {carrier.full_name} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута" | ||||
|           required | ||||
|           value={editRouteData.route_number || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               route_number: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Координаты маршрута" | ||||
|           multiline | ||||
|           minRows={3} | ||||
|           value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               path: e.target.value | ||||
|                 .split("\n") | ||||
|                 .map((line) => line.split(" ").map(Number)), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Номер маршрута в Говорящем Городе" | ||||
|           required | ||||
|           value={editRouteData.route_sys_number || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               route_sys_number: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Обращение губернатора</InputLabel> | ||||
|           <Select | ||||
|             value={editRouteData.governor_appeal || ""} | ||||
|             label="Обращение губернатора" | ||||
|               ))} | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута" | ||||
|             required | ||||
|             value={editRouteData.route_number || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 governor_appeal: Number(e.target.value), | ||||
|                 route_number: e.target.value, | ||||
|               }) | ||||
|             } | ||||
|             disabled={articlesStore.articleList.ru.data.length === 0} | ||||
|           > | ||||
|             <MenuItem value="">Не выбрано</MenuItem> | ||||
|             {articlesStore.articleList.ru.data.map( | ||||
|               (a: (typeof articlesStore.articleList.ru.data)[number]) => ( | ||||
|                 <MenuItem key={a.id} value={a.id}> | ||||
|                   {a.heading} | ||||
|                 </MenuItem> | ||||
|               ) | ||||
|             )} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <FormControl fullWidth required> | ||||
|           <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|           <Select | ||||
|             value={editRouteData.route_direction ? "forward" : "backward"} | ||||
|             label="Прямой/обратный маршрут" | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Координаты маршрута" | ||||
|             multiline | ||||
|             minRows={2} | ||||
|             maxRows={10} | ||||
|             value={coordinates} | ||||
|             onChange={(e) => { | ||||
|               const newValue = e.target.value; | ||||
|               setCoordinates(newValue); | ||||
|  | ||||
|               const validationResult = validateCoordinates(newValue); | ||||
|               if (validationResult === true) { | ||||
|                 const lines = newValue.trim().split("\n"); | ||||
|                 const path = lines.map((line) => { | ||||
|                   const [lat, lon] = line | ||||
|                     .trim() | ||||
|                     .split(/[\s,]+/) | ||||
|                     .map(Number); | ||||
|                   return [lat, lon]; | ||||
|                 }); | ||||
|                 routeStore.setEditRouteData({ path }); | ||||
|               } | ||||
|             }} | ||||
|             onKeyDown={(e) => { | ||||
|               if (e.key === "Enter") { | ||||
|                 const lines = coordinates.split("\n"); | ||||
|                 const lastLine = lines[lines.length - 1]; | ||||
|  | ||||
|                 // Если мы на последней строке и она не пустая | ||||
|                 if (lastLine && lastLine.trim()) { | ||||
|                   e.preventDefault(); | ||||
|                   const newValue = coordinates + "\n"; | ||||
|                   setCoordinates(newValue); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|             error={validateCoordinates(coordinates) !== true} | ||||
|             helperText={ | ||||
|               typeof validateCoordinates(coordinates) === "string" | ||||
|                 ? validateCoordinates(coordinates) | ||||
|                 : "Формат: широта долгота" | ||||
|             } | ||||
|             placeholder="55.7558 37.6173
55.7539 37.6208" | ||||
|             sx={{ | ||||
|               "& .MuiInputBase-root": { | ||||
|                 maxHeight: "500px", | ||||
|                 overflow: "auto", | ||||
|               }, | ||||
|               "& .MuiInputBase-input": { | ||||
|                 fontFamily: "monospace", | ||||
|                 fontSize: "0.8rem", | ||||
|                 lineHeight: "1.2", | ||||
|                 padding: "8px 12px", | ||||
|               }, | ||||
|               "& .MuiFormHelperText-root": { | ||||
|                 fontSize: "0.75rem", | ||||
|                 marginTop: "2px", | ||||
|               }, | ||||
|             }} | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Номер маршрута в Говорящем Городе" | ||||
|             required | ||||
|             value={editRouteData.route_sys_number || ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 route_direction: e.target.value === "forward", | ||||
|                 route_sys_number: e.target.value, | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|  | ||||
|           {/* Заменяем Select на кнопку для выбора статьи */} | ||||
|           <Box className="flex flex-col gap-2"> | ||||
|             <label className="text-sm font-medium text-gray-700"> | ||||
|               Обращение к пассажирам | ||||
|             </label> | ||||
|             <Box className="flex gap-2"> | ||||
|               <TextField | ||||
|                 className="flex-1" | ||||
|                 value={selectedArticle?.heading || "Статья не выбрана"} | ||||
|                 placeholder="Выберите статью" | ||||
|                 disabled | ||||
|                 sx={{ | ||||
|                   "& .MuiInputBase-input": { | ||||
|                     color: selectedArticle ? "inherit" : "#999", | ||||
|                   }, | ||||
|                 }} | ||||
|               /> | ||||
|               <Button | ||||
|                 variant="outlined" | ||||
|                 onClick={() => setIsSelectArticleDialogOpen(true)} | ||||
|                 startIcon={<Plus size={16} />} | ||||
|                 sx={{ minWidth: "auto", px: 2 }} | ||||
|               > | ||||
|                 Выбрать | ||||
|               </Button> | ||||
|             </Box> | ||||
|           </Box> | ||||
|  | ||||
|           {/* Селектор видеозаставки */} | ||||
|           <Box className="flex flex-col gap-2"> | ||||
|             <label className="text-sm font-medium text-gray-700"> | ||||
|               Видеозаставка | ||||
|             </label> | ||||
|             <Box className="flex gap-2"> | ||||
|               <Box | ||||
|                 className="flex-1" | ||||
|                 onClick={handleVideoPreviewClick} | ||||
|                 sx={{ | ||||
|                   cursor: | ||||
|                     editRouteData.video_preview && | ||||
|                     editRouteData.video_preview !== "" | ||||
|                       ? "pointer" | ||||
|                       : "default", | ||||
|                 }} | ||||
|               > | ||||
|                 <Box | ||||
|                   className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4" | ||||
|                   sx={{ | ||||
|                     "& .MuiInputBase-input": { | ||||
|                       color: | ||||
|                         editRouteData.video_preview && | ||||
|                         editRouteData.video_preview !== "" | ||||
|                           ? "inherit" | ||||
|                           : "#999", | ||||
|                       cursor: | ||||
|                         editRouteData.video_preview && | ||||
|                         editRouteData.video_preview !== "" | ||||
|                           ? "pointer" | ||||
|                           : "default", | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
|                   <Typography variant="body1" className="text-sm"> | ||||
|                     {editRouteData.video_preview && | ||||
|                     editRouteData.video_preview !== "" | ||||
|                       ? "Видео выбрано" | ||||
|                       : "Видео не выбрано"} | ||||
|                   </Typography> | ||||
|                   {editRouteData.video_preview && | ||||
|                     editRouteData.video_preview !== "" && ( | ||||
|                       <Box | ||||
|                         onClick={(e) => { | ||||
|                           e.stopPropagation(); | ||||
|                           routeStore.setEditRouteData({ video_preview: "" }); | ||||
|                         }} | ||||
|                         sx={{ | ||||
|                           cursor: "pointer", | ||||
|                           color: "#999", | ||||
|                           "&:hover": { | ||||
|                             color: "#666", | ||||
|                           }, | ||||
|                         }} | ||||
|                       > | ||||
|                         <Typography | ||||
|                           variant="body1" | ||||
|                           className="text-lg font-bold" | ||||
|                         > | ||||
|                           × | ||||
|                         </Typography> | ||||
|                       </Box> | ||||
|                     )} | ||||
|                 </Box> | ||||
|               </Box> | ||||
|               <Button | ||||
|                 variant="outlined" | ||||
|                 onClick={() => setIsSelectVideoDialogOpen(true)} | ||||
|                 startIcon={<Plus size={16} />} | ||||
|                 sx={{ minWidth: "auto", px: 2 }} | ||||
|               > | ||||
|                 Выбрать | ||||
|               </Button> | ||||
|             </Box> | ||||
|           </Box> | ||||
|  | ||||
|           <FormControl fullWidth required> | ||||
|             <InputLabel>Прямой/обратный маршрут</InputLabel> | ||||
|             <Select | ||||
|               value={editRouteData.route_direction ? "forward" : "backward"} | ||||
|               label="Прямой/обратный маршрут" | ||||
|               onChange={(e) => | ||||
|                 routeStore.setEditRouteData({ | ||||
|                   route_direction: e.target.value === "forward", | ||||
|                 }) | ||||
|               } | ||||
|             > | ||||
|               <MenuItem value="forward">Прямой</MenuItem> | ||||
|               <MenuItem value="backward">Обратный</MenuItem> | ||||
|             </Select> | ||||
|           </FormControl> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (мин)" | ||||
|             value={editRouteData.scale_min ?? ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 scale_min: | ||||
|                   e.target.value === "" ? null : parseFloat(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Масштаб (макс)" | ||||
|             value={editRouteData.scale_max ?? ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 scale_max: | ||||
|                   e.target.value === "" ? null : parseFloat(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Поворот" | ||||
|             value={editRouteData.rotate ?? ""} | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 rotate: | ||||
|                   e.target.value === "" ? null : parseFloat(e.target.value), | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. широта" | ||||
|             value={editRouteData.center_latitude ?? ""} | ||||
|             type="text" | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 center_latitude: e.target.value, | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Центр. долгота" | ||||
|             value={editRouteData.center_longitude ?? ""} | ||||
|             type="text" | ||||
|             onChange={(e) => | ||||
|               routeStore.setEditRouteData({ | ||||
|                 center_longitude: e.target.value, | ||||
|               }) | ||||
|             } | ||||
|           /> | ||||
|         </Box> | ||||
|  | ||||
|         <LinkedItems | ||||
|           parentId={id || ""} | ||||
|           type="edit" | ||||
|           dragAllowed={true} | ||||
|           fields={[ | ||||
|             { label: "Название", data: "name" }, | ||||
|             { label: "Описание", data: "description" }, | ||||
|           ]} | ||||
|           onUpdate={() => { | ||||
|             routeStore.getRoute(Number(id)); | ||||
|           }} | ||||
|           routeDirection={editRouteData.route_direction} | ||||
|         /> | ||||
|  | ||||
|         <div className="flex w-full justify-between"> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             className="w-min flex gap-2 items-center" | ||||
|             startIcon={<Copy size={20} />} | ||||
|             onClick={handleCopy} | ||||
|             disabled={isLoading} | ||||
|           > | ||||
|             <MenuItem value="forward">Прямой</MenuItem> | ||||
|             <MenuItem value="backward">Обратный</MenuItem> | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (мин)" | ||||
|           value={editRouteData.scale_min || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               scale_min: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Масштаб (макс)" | ||||
|           value={editRouteData.scale_max || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               scale_max: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Поворот" | ||||
|           value={editRouteData.rotate || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               rotate: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. широта" | ||||
|           value={editRouteData.center_latitude || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               center_latitude: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Центр. долгота" | ||||
|           value={editRouteData.center_longitude || ""} | ||||
|           onChange={(e) => | ||||
|             routeStore.setEditRouteData({ | ||||
|               center_longitude: Number(e.target.value), | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|       </Box> | ||||
|       <div className="flex w-full justify-end"> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleSave} | ||||
|           disabled={isLoading} | ||||
|         > | ||||
|           Сохранить | ||||
|         </Button> | ||||
|             Скопировать | ||||
|           </Button> | ||||
|  | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             className="w-min flex gap-2 items-center" | ||||
|             startIcon={<Save size={20} />} | ||||
|             onClick={handleSave} | ||||
|             disabled={isLoading} | ||||
|           > | ||||
|             Сохранить | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {/* Модальное окно выбора статьи */} | ||||
|       <SelectArticleModal | ||||
|         open={isSelectArticleDialogOpen} | ||||
|         onClose={() => setIsSelectArticleDialogOpen(false)} | ||||
|         onSelectArticle={handleArticleSelect} | ||||
|       /> | ||||
|  | ||||
|       {/* Модальное окно выбора видео */} | ||||
|       <SelectMediaDialog | ||||
|         open={isSelectVideoDialogOpen} | ||||
|         onClose={() => setIsSelectVideoDialogOpen(false)} | ||||
|         onSelectMedia={handleVideoSelect} | ||||
|         mediaType={2} | ||||
|       /> | ||||
|  | ||||
|       {/* Модальное окно предпросмотра видео */} | ||||
|       <Dialog | ||||
|         open={isVideoPreviewOpen} | ||||
|         onClose={() => setIsVideoPreviewOpen(false)} | ||||
|         maxWidth="md" | ||||
|         fullWidth | ||||
|       > | ||||
|         <DialogTitle>Предпросмотр видео</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <Box className="flex justify-center items-center p-4"> | ||||
|             <MediaViewer | ||||
|               media={{ | ||||
|                 id: editRouteData.video_preview, | ||||
|                 media_type: 2, | ||||
|                 filename: "video_preview", | ||||
|               }} | ||||
|             /> | ||||
|           </Box> | ||||
|         </DialogContent> | ||||
|         <DialogActions> | ||||
|           <Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button> | ||||
|         </DialogActions> | ||||
|       </Dialog> | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,32 +1,70 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, routeStore } from "@shared"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { carrierStore, languageStore, routeStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Map, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Map, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const RouteListPage = observer(() => { | ||||
|   const { routes, getRoutes, deleteRoute } = routeStore; | ||||
|   const { carriers, getCarriers } = carrierStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getRoutes(); | ||||
|     const fetchData = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getCarriers("ru"); | ||||
|       await getCarriers("en"); | ||||
|       await getCarriers("zh"); | ||||
|       await getRoutes(); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
|     { | ||||
|       field: "carrier", | ||||
|       field: "carrier_id", | ||||
|       headerName: "Перевозчик", | ||||
|       width: 250, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               carriers[language].data.find( | ||||
|                 (carrier) => carrier.id == params.value | ||||
|               )?.short_name | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "route_number", | ||||
|       headerName: "Номер маршрута", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "route_direction", | ||||
| @@ -52,6 +90,7 @@ export const RouteListPage = observer(() => { | ||||
|       width: 250, | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="flex h-full gap-7 justify-center items-center"> | ||||
| @@ -80,23 +119,51 @@ export const RouteListPage = observer(() => { | ||||
|  | ||||
|   const rows = routes.data.map((route) => ({ | ||||
|     id: route.id, | ||||
|     carrier: route.carrier, | ||||
|     carrier_id: route.carrier_id, | ||||
|     route_number: route.route_number, | ||||
|     route_direction: route.route_direction ? "Прямой" : "Обратный", | ||||
|   })); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div style={{ width: "100%" }}> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Маршруты</h1> | ||||
|           <CreateButton label="Создать маршрут" path="/route/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids as unknown as number[])); | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -114,6 +181,19 @@ export const RouteListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteRoute(id))); | ||||
|           getRoutes(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| export const UP_SCALE = 30000; | ||||
| export const PATH_WIDTH = 15; | ||||
| export const STATION_RADIUS = 20; | ||||
| export const STATION_OUTLINE_WIDTH = 10; | ||||
| export const SIGHT_SIZE = 60; | ||||
| export const UP_SCALE = 10000; | ||||
| export const PATH_WIDTH = 5; | ||||
| export const STATION_RADIUS = 8; | ||||
| export const STATION_OUTLINE_WIDTH = 4; | ||||
| export const SIGHT_SIZE = 40; | ||||
| export const SCALE_FACTOR = 50; | ||||
|  | ||||
| export const BACKGROUND_COLOR = 0x111111; | ||||
| export const PATH_COLOR = 0xff4d4d; | ||||
| export const PATH_COLOR = 0xff4d4d; | ||||
|   | ||||
| @@ -37,7 +37,7 @@ export function InfiniteCanvas({ | ||||
|     setScreenCenter, | ||||
|     screenCenter, | ||||
|   } = useTransform(); | ||||
|   const { routeData, originalRouteData } = useMapData(); | ||||
|   const { routeData, originalRouteData, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   const applicationRef = useApplication(); | ||||
|  | ||||
| @@ -45,6 +45,7 @@ export function InfiniteCanvas({ | ||||
|   const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||
|   const [startRotation, setStartRotation] = useState(0); | ||||
|   const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|   const [isPointerDown, setIsPointerDown] = useState(false); | ||||
|  | ||||
|   // Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута | ||||
|   const [isUserInteracting, setIsUserInteracting] = useState(false); | ||||
| @@ -53,19 +54,20 @@ export function InfiniteCanvas({ | ||||
|   const lastOriginalRotation = useRef<number | undefined>(undefined); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const canvas = applicationRef?.app.canvas; | ||||
|     if (!canvas) return; | ||||
|     if (!applicationRef?.app?.canvas) return; | ||||
|  | ||||
|     const canvas = applicationRef.app.canvas; | ||||
|     const canvasRect = canvas.getBoundingClientRect(); | ||||
|     const canvasLeft = canvasRect.left; | ||||
|     const canvasTop = canvasRect.top; | ||||
|     const centerX = window.innerWidth / 2 - canvasLeft; | ||||
|     const centerY = window.innerHeight / 2 - canvasTop; | ||||
|     setScreenCenter({ x: centerX, y: centerY }); | ||||
|   }, [applicationRef?.app.canvas, setScreenCenter]); | ||||
|   }, [applicationRef?.app, setScreenCenter]); | ||||
|  | ||||
|   const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|     setIsDragging(true); | ||||
|     setIsPointerDown(true); | ||||
|     setIsDragging(false); | ||||
|     setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя | ||||
|     setStartPosition({ | ||||
|       x: position.x, | ||||
| @@ -93,7 +95,18 @@ export function InfiniteCanvas({ | ||||
|   }, [originalRouteData?.rotate, isUserInteracting, setRotation]); | ||||
|  | ||||
|   const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|     if (!isDragging) return; | ||||
|     if (!isPointerDown) return; | ||||
|  | ||||
|     // Проверяем, началось ли перетаскивание | ||||
|     if (!isDragging) { | ||||
|       const dx = e.globalX - startMousePosition.x; | ||||
|       const dy = e.globalY - startMousePosition.y; | ||||
|       if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { | ||||
|         setIsDragging(true); | ||||
|       } else { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (e.shiftKey) { | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
| @@ -136,6 +149,12 @@ export function InfiniteCanvas({ | ||||
|   }; | ||||
|  | ||||
|   const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|     // Если не было перетаскивания, то это простой клик - закрываем виджет | ||||
|     if (!isDragging) { | ||||
|       setSelectedSight(undefined); | ||||
|     } | ||||
|  | ||||
|     setIsPointerDown(false); | ||||
|     setIsDragging(false); | ||||
|     // Сбрасываем флаг взаимодействия через небольшую задержку | ||||
|     // чтобы избежать немедленного срабатывания useEffect | ||||
| @@ -185,7 +204,6 @@ export function InfiniteCanvas({ | ||||
|  | ||||
|   useEffect(() => { | ||||
|     applicationRef?.app.render(); | ||||
|     console.log(position, scale, rotation); | ||||
|   }, [position, scale, rotation]); | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -1,10 +1,30 @@ | ||||
| import { Stack, Typography, Button } from "@mui/material"; | ||||
|  | ||||
| import { useNavigate, useNavigationType } from "react-router"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { authInstance } from "@shared"; | ||||
|  | ||||
| export function LeftSidebar() { | ||||
| export const LeftSidebar = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const navigationType = useNavigationType(); // PUSH, POP, REPLACE | ||||
|   const { routeData } = useMapData(); | ||||
|   const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null); | ||||
|   const [carrierLogo, setCarrierLogo] = useState<string | null>(null); | ||||
|   useEffect(() => { | ||||
|     async function fetchCarrierThumbnail() { | ||||
|       if (routeData?.carrier_id) { | ||||
|         const { city_id, logo } = ( | ||||
|           await authInstance.get(`/carrier/${routeData.carrier_id}`) | ||||
|         ).data; | ||||
|         const { arms } = (await authInstance.get(`/city/${city_id}`)).data; | ||||
|         setCarrierThumbnail(arms); | ||||
|         setCarrierLogo(logo); | ||||
|       } | ||||
|     } | ||||
|     fetchCarrierThumbnail(); | ||||
|   }, [routeData?.carrier_id]); | ||||
|  | ||||
|   const handleBack = () => { | ||||
|     if (navigationType === "PUSH") { | ||||
| @@ -27,6 +47,7 @@ export function LeftSidebar() { | ||||
|           color: "#fff", | ||||
|           backgroundColor: "#222", | ||||
|           borderRadius: 10, | ||||
|           height: 40, | ||||
|           width: "100%", | ||||
|           border: "none", | ||||
|           cursor: "pointer", | ||||
| @@ -41,10 +62,30 @@ export function LeftSidebar() { | ||||
|         justifyContent="center" | ||||
|         my={10} | ||||
|       > | ||||
|         <img src={"/Emblem.svg"} alt="logo" width={100} height={100} /> | ||||
|         <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> | ||||
|           При поддержке Правительства Санкт-Петербурга | ||||
|         </Typography> | ||||
|         <div | ||||
|           style={{ | ||||
|             maxWidth: 200, | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center", | ||||
|             gap: 10, | ||||
|           }} | ||||
|         > | ||||
|           {carrierThumbnail && ( | ||||
|             <MediaViewer | ||||
|               media={{ | ||||
|                 id: carrierThumbnail, | ||||
|                 media_type: 1, // Тип "Фото" для логотипа | ||||
|                 filename: "route_thumbnail", | ||||
|               }} | ||||
|               fullWidth | ||||
|               fullHeight | ||||
|             /> | ||||
|           )} | ||||
|           <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> | ||||
|             При поддержке Правительства | ||||
|           </Typography>{" "} | ||||
|         </div> | ||||
|       </Stack> | ||||
|  | ||||
|       <Stack | ||||
| @@ -65,15 +106,20 @@ export function LeftSidebar() { | ||||
|       <Stack | ||||
|         direction="column" | ||||
|         alignItems="center" | ||||
|         maxHeight={150} | ||||
|         justifyContent="center" | ||||
|         my={10} | ||||
|       > | ||||
|         <img | ||||
|           src={"/GET.png"} | ||||
|           alt="logo" | ||||
|           width="80%" | ||||
|           style={{ margin: "0 auto" }} | ||||
|         /> | ||||
|         {carrierLogo && ( | ||||
|           <MediaViewer | ||||
|             media={{ | ||||
|               id: carrierLogo, | ||||
|               media_type: 1, // Тип "Фото" для логотипа | ||||
|               filename: "route_thumbnail_logo", | ||||
|             }} | ||||
|             fullHeight | ||||
|           /> | ||||
|         )} | ||||
|       </Stack> | ||||
|  | ||||
|       <Typography | ||||
| @@ -86,4 +132,4 @@ export function LeftSidebar() { | ||||
|       </Typography> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
| }); | ||||
|   | ||||
| @@ -29,10 +29,13 @@ const MapDataContext = createContext<{ | ||||
|   isRouteLoading: boolean; | ||||
|   isStationLoading: boolean; | ||||
|   isSightLoading: boolean; | ||||
|   selectedSight?: SightData; | ||||
|   setSelectedSight: (sight?: SightData) => void; | ||||
|   setScaleRange: (min: number, max: number) => void; | ||||
|   setMapRotation: (rotation: number) => void; | ||||
|   setMapCenter: (x: number, y: number) => void; | ||||
|   setStationOffset: (stationId: number, x: number, y: number) => void; | ||||
|   setStationAlign: (stationId: number, align: number) => void; | ||||
|   setSightCoordinates: ( | ||||
|     sightId: number, | ||||
|     latitude: number, | ||||
| @@ -50,10 +53,13 @@ const MapDataContext = createContext<{ | ||||
|   isRouteLoading: true, | ||||
|   isStationLoading: true, | ||||
|   isSightLoading: true, | ||||
|   selectedSight: undefined, | ||||
|   setSelectedSight: () => {}, | ||||
|   setScaleRange: () => {}, | ||||
|   setMapRotation: () => {}, | ||||
|   setMapCenter: () => {}, | ||||
|   setStationOffset: () => {}, | ||||
|   setStationAlign: () => {}, | ||||
|   setSightCoordinates: () => {}, | ||||
|   saveChanges: () => {}, | ||||
| }); | ||||
| @@ -87,6 +93,7 @@ export const MapDataProvider = observer( | ||||
|     const [isRouteLoading, setIsRouteLoading] = useState(true); | ||||
|     const [isStationLoading, setIsStationLoading] = useState(true); | ||||
|     const [isSightLoading, setIsSightLoading] = useState(true); | ||||
|     const [selectedSight, setSelectedSight] = useState<SightData>(); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       const fetchData = async () => { | ||||
| @@ -106,17 +113,18 @@ export const MapDataProvider = observer( | ||||
|             languageInstance("ru").get(`/route/${routeId}/station`), | ||||
|             languageInstance("en").get(`/route/${routeId}/station`), | ||||
|             languageInstance("zh").get(`/route/${routeId}/station`), | ||||
|             authInstance.get(`/route/${routeId}/sight`), | ||||
|             languageInstance("ru").get(`/route/${routeId}/sight`), | ||||
|           ]); | ||||
|  | ||||
|           setOriginalRouteData(routeResponse.data as RouteData); | ||||
|           const routeData = routeResponse.data as RouteData; | ||||
|           setOriginalRouteData(routeData); | ||||
|           setOriginalStationData(ruStationResponse.data as StationData[]); | ||||
|           setStationData({ | ||||
|             ru: ruStationResponse.data as StationData[], | ||||
|             en: enStationResponse.data as StationData[], | ||||
|             zh: zhStationResponse.data as StationData[], | ||||
|           }); | ||||
|           setOriginalSightData(sightResponse as unknown as SightData[]); | ||||
|           setOriginalSightData(sightResponse.data as SightData[]); | ||||
|  | ||||
|           setIsRouteLoading(false); | ||||
|           setIsStationLoading(false); | ||||
| @@ -176,43 +184,136 @@ export const MapDataProvider = observer( | ||||
|     } | ||||
|  | ||||
|     async function saveSightChanges() { | ||||
|       console.log("sightChanges", sightChanges); | ||||
|       for (const sight of sightChanges) { | ||||
|         await authInstance.patch(`/route/${routeId}/sight`, sight); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function setStationOffset(stationId: number, x: number, y: number) { | ||||
|       setStationChanges((prev) => { | ||||
|         let found = prev.find((station) => station.station_id === stationId); | ||||
|         if (found) { | ||||
|           found.offset_x = x; | ||||
|           found.offset_y = y; | ||||
|       const currentStation = stationData.ru?.find( | ||||
|         (station) => station.id === stationId | ||||
|       ); | ||||
|       if ( | ||||
|         currentStation && | ||||
|         Math.abs(currentStation.offset_x - x) < 0.01 && | ||||
|         Math.abs(currentStation.offset_y - y) < 0.01 | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|           return prev.map((station) => { | ||||
|             if (station.station_id === stationId) { | ||||
|               return found; | ||||
|       setStationChanges((prev) => { | ||||
|         const existingIndex = prev.findIndex( | ||||
|           (station) => station.station_id === stationId | ||||
|         ); | ||||
|  | ||||
|         if (existingIndex !== -1) { | ||||
|           const newChanges = [...prev]; | ||||
|           newChanges[existingIndex] = { | ||||
|             ...newChanges[existingIndex], | ||||
|             offset_x: x, | ||||
|             offset_y: y, | ||||
|           }; | ||||
|           return newChanges; | ||||
|         } else { | ||||
|           const originalStation = originalStationData?.find( | ||||
|             (s) => s.id === stationId | ||||
|           ); | ||||
|           return [ | ||||
|             ...prev, | ||||
|             { | ||||
|               station_id: stationId, | ||||
|               offset_x: x, | ||||
|               offset_y: y, | ||||
|               align: originalStation?.align ?? 1, | ||||
|               transfers: originalStation?.transfers ?? { | ||||
|                 bus: "", | ||||
|                 metro_blue: "", | ||||
|                 metro_green: "", | ||||
|                 metro_orange: "", | ||||
|                 metro_purple: "", | ||||
|                 metro_red: "", | ||||
|                 train: "", | ||||
|                 tram: "", | ||||
|                 trolleybus: "", | ||||
|               }, | ||||
|             }, | ||||
|           ]; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       setStationData((prev) => { | ||||
|         const updated = { ...prev }; | ||||
|         Object.keys(updated).forEach((lang) => { | ||||
|           updated[lang] = updated[lang].map((station) => { | ||||
|             if (station.id === stationId) { | ||||
|               return { ...station, offset_x: x, offset_y: y }; | ||||
|             } | ||||
|             return station; | ||||
|           }); | ||||
|         }); | ||||
|         return updated; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setStationAlign(stationId: number, align: number) { | ||||
|       const currentStation = stationData.ru?.find( | ||||
|         (station) => station.id === stationId | ||||
|       ); | ||||
|       if (currentStation && currentStation.align === align) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       setStationChanges((prev) => { | ||||
|         const existingIndex = prev.findIndex( | ||||
|           (station) => station.station_id === stationId | ||||
|         ); | ||||
|  | ||||
|         if (existingIndex !== -1) { | ||||
|           const newChanges = [...prev]; | ||||
|           newChanges[existingIndex] = { | ||||
|             ...newChanges[existingIndex], | ||||
|             align: align, | ||||
|           }; | ||||
|           return newChanges; | ||||
|         } else { | ||||
|           const foundStation = stationData.ru?.find( | ||||
|             (station) => station.id === stationId | ||||
|           const originalStation = originalStationData?.find( | ||||
|             (s) => s.id === stationId | ||||
|           ); | ||||
|           if (foundStation) { | ||||
|             return [ | ||||
|               ...prev, | ||||
|               { | ||||
|                 station_id: stationId, | ||||
|                 offset_x: x, | ||||
|                 offset_y: y, | ||||
|                 transfers: foundStation.transfers, | ||||
|           return [ | ||||
|             ...prev, | ||||
|             { | ||||
|               station_id: stationId, | ||||
|               align: align, | ||||
|               offset_x: originalStation?.offset_x ?? 0, | ||||
|               offset_y: originalStation?.offset_y ?? 0, | ||||
|               transfers: originalStation?.transfers ?? { | ||||
|                 bus: "", | ||||
|                 metro_blue: "", | ||||
|                 metro_green: "", | ||||
|                 metro_orange: "", | ||||
|                 metro_purple: "", | ||||
|                 metro_red: "", | ||||
|                 train: "", | ||||
|                 tram: "", | ||||
|                 trolleybus: "", | ||||
|               }, | ||||
|             ]; | ||||
|           } | ||||
|           return prev; | ||||
|             }, | ||||
|           ]; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       setStationData((prev) => { | ||||
|         const updated = { ...prev }; | ||||
|         Object.keys(updated).forEach((lang) => { | ||||
|           updated[lang] = updated[lang].map((station) => { | ||||
|             if (station.id === stationId) { | ||||
|               return { ...station, align: align }; | ||||
|             } | ||||
|             return station; | ||||
|           }); | ||||
|         }); | ||||
|         return updated; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setSightCoordinates( | ||||
| @@ -221,14 +322,18 @@ export const MapDataProvider = observer( | ||||
|       longitude: number | ||||
|     ) { | ||||
|       setSightChanges((prev) => { | ||||
|         let found = prev.find((sight) => sight.sight_id === sightId); | ||||
|         if (found) { | ||||
|           found.latitude = latitude; | ||||
|           found.longitude = longitude; | ||||
|         const existingIndex = prev.findIndex( | ||||
|           (sight) => sight.sight_id === sightId | ||||
|         ); | ||||
|  | ||||
|           return prev.map((sight) => { | ||||
|             if (sight.sight_id === sightId) { | ||||
|               return found; | ||||
|         if (existingIndex !== -1) { | ||||
|           return prev.map((sight, index) => { | ||||
|             if (index === existingIndex) { | ||||
|               return { | ||||
|                 ...sight, | ||||
|                 latitude, | ||||
|                 longitude, | ||||
|               }; | ||||
|             } | ||||
|             return sight; | ||||
|           }); | ||||
| @@ -249,9 +354,7 @@ export const MapDataProvider = observer( | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     useEffect(() => { | ||||
|       console.log("sightChanges", sightChanges); | ||||
|     }, [sightChanges]); | ||||
|     useEffect(() => {}, [sightChanges]); | ||||
|  | ||||
|     const value = useMemo( | ||||
|       () => ({ | ||||
| @@ -264,11 +367,14 @@ export const MapDataProvider = observer( | ||||
|         isRouteLoading, | ||||
|         isStationLoading, | ||||
|         isSightLoading, | ||||
|         selectedSight, | ||||
|         setSelectedSight, | ||||
|         setScaleRange, | ||||
|         setMapRotation, | ||||
|         setMapCenter, | ||||
|         saveChanges, | ||||
|         setStationOffset, | ||||
|         setStationAlign, | ||||
|         setSightCoordinates, | ||||
|       }), | ||||
|       [ | ||||
| @@ -281,6 +387,7 @@ export const MapDataProvider = observer( | ||||
|         isRouteLoading, | ||||
|         isStationLoading, | ||||
|         isSightLoading, | ||||
|         selectedSight, | ||||
|       ] | ||||
|     ); | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { Button, Stack, TextField, Typography } from "@mui/material"; | ||||
| import { Button, Stack, TextField, Typography, Slider } from "@mui/material"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||
| import { SCALE_FACTOR } from "./Constants"; | ||||
| import { toast } from "react-toastify"; | ||||
|  | ||||
| export function RightSidebar() { | ||||
|   const { | ||||
| @@ -20,19 +22,31 @@ export function RightSidebar() { | ||||
|     screenCenter, | ||||
|     rotateToAngle, | ||||
|     setTransform, | ||||
|     scale, | ||||
|     setScaleAtCenter, | ||||
|   } = useTransform(); | ||||
|  | ||||
|   const [minScale, setMinScale] = useState<number>(1); | ||||
|   const [maxScale, setMaxScale] = useState<number>(10); | ||||
|   const [maxScale, setMaxScale] = useState<number>(5); | ||||
|   const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({ | ||||
|     x: 0, | ||||
|     y: 0, | ||||
|   }); | ||||
|   const [rotationDegrees, setRotationDegrees] = useState<number>(0); | ||||
|   const [isUserEditing, setIsUserEditing] = useState<boolean>(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (originalRouteData) { | ||||
|       setMinScale(originalRouteData.scale_min ?? 1); | ||||
|       setMaxScale(originalRouteData.scale_max ?? 10); | ||||
|       // Проверяем и сбрасываем минимальный масштаб если нужно | ||||
|       const originalMinScale = originalRouteData.scale_min ?? 1; | ||||
|       const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; | ||||
|  | ||||
|       // Проверяем и сбрасываем максимальный масштаб если нужно | ||||
|       const originalMaxScale = originalRouteData.scale_max ?? 5; | ||||
|       const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; | ||||
|  | ||||
|       setMinScale(resetMinScale); | ||||
|       setMaxScale(resetMaxScale); | ||||
|       setRotationDegrees(originalRouteData.rotate ?? 0); | ||||
|       setLocalCenter({ | ||||
|         x: originalRouteData.center_latitude ?? 0, | ||||
| @@ -52,16 +66,26 @@ export function RightSidebar() { | ||||
|       ((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360 | ||||
|     ); | ||||
|   }, [rotation]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setMapRotation(rotationDegrees); | ||||
|   }, [rotationDegrees]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|     const localCenter = screenToLocal(center.x, center.y); | ||||
|     const coordinates = localToCoordinates(localCenter.x, localCenter.y); | ||||
|     setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude }); | ||||
|   }, [position]); | ||||
|     if (!isUserEditing) { | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|       const localCenter = screenToLocal(center.x, center.y); | ||||
|       const coordinates = localToCoordinates(localCenter.x, localCenter.y); | ||||
|       setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude }); | ||||
|     } | ||||
|   }, [ | ||||
|     position, | ||||
|     screenCenter, | ||||
|     screenToLocal, | ||||
|     localToCoordinates, | ||||
|     setLocalCenter, | ||||
|     isUserEditing, | ||||
|   ]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setMapCenter(localCenter.x, localCenter.y); | ||||
| @@ -77,7 +101,6 @@ export function RightSidebar() { | ||||
|   } | ||||
|  | ||||
|   if (!routeData) { | ||||
|     console.error("routeData is null"); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
| @@ -104,7 +127,30 @@ export function RightSidebar() { | ||||
|           label="Минимальный масштаб" | ||||
|           variant="filled" | ||||
|           value={minScale} | ||||
|           onChange={(e) => setMinScale(Number(e.target.value))} | ||||
|           onChange={(e) => { | ||||
|             let newMinScale = Number(e.target.value); | ||||
|  | ||||
|             // Сбрасываем к 1 если меньше | ||||
|             if (newMinScale < 1) { | ||||
|               newMinScale = 1; | ||||
|             } | ||||
|  | ||||
|             setMinScale(newMinScale); | ||||
|  | ||||
|             if (maxScale - newMinScale < 2) { | ||||
|               let newMaxScale = newMinScale + 2; | ||||
|               // Сбрасываем максимальный к 3 если меньше минимального | ||||
|               if (newMaxScale < 3) { | ||||
|                 newMaxScale = 3; | ||||
|                 setMinScale(1); // Сбрасываем минимальный к 1 | ||||
|               } | ||||
|               setMaxScale(newMaxScale); | ||||
|             } | ||||
|  | ||||
|             if (newMinScale > scale * SCALE_FACTOR) { | ||||
|               setScaleAtCenter(newMinScale / SCALE_FACTOR); | ||||
|             } | ||||
|           }} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -116,7 +162,8 @@ export function RightSidebar() { | ||||
|           }} | ||||
|           slotProps={{ | ||||
|             input: { | ||||
|               min: 0.1, | ||||
|               min: 1, | ||||
|               max: 10, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
| @@ -125,7 +172,30 @@ export function RightSidebar() { | ||||
|           label="Максимальный масштаб" | ||||
|           variant="filled" | ||||
|           value={maxScale} | ||||
|           onChange={(e) => setMaxScale(Number(e.target.value))} | ||||
|           onChange={(e) => { | ||||
|             let newMaxScale = Number(e.target.value); | ||||
|  | ||||
|             // Сбрасываем к 3 если меньше минимального | ||||
|             if (newMaxScale < 3) { | ||||
|               newMaxScale = 3; | ||||
|             } | ||||
|  | ||||
|             setMaxScale(newMaxScale); | ||||
|  | ||||
|             if (newMaxScale - minScale < 2) { | ||||
|               let newMinScale = newMaxScale - 2; | ||||
|               // Сбрасываем минимальный к 1 если меньше | ||||
|               if (newMinScale < 1) { | ||||
|                 newMinScale = 1; | ||||
|                 setMaxScale(3); // Сбрасываем максимальный к минимальному значению | ||||
|               } | ||||
|               setMinScale(newMinScale); | ||||
|             } | ||||
|  | ||||
|             if (newMaxScale < scale * SCALE_FACTOR) { | ||||
|               setScaleAtCenter(newMaxScale / SCALE_FACTOR); | ||||
|             } | ||||
|           }} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -137,12 +207,71 @@ export function RightSidebar() { | ||||
|           }} | ||||
|           slotProps={{ | ||||
|             input: { | ||||
|               min: 0.1, | ||||
|               min: 3, | ||||
|               max: 10, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|  | ||||
|       <Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}> | ||||
|         Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100} | ||||
|       </Typography> | ||||
|  | ||||
|       <Slider | ||||
|         value={scale * SCALE_FACTOR} | ||||
|         onChange={(_, newValue) => { | ||||
|           if (typeof newValue === "number") { | ||||
|             setScaleAtCenter(newValue / SCALE_FACTOR); | ||||
|           } | ||||
|         }} | ||||
|         min={minScale} | ||||
|         max={maxScale} | ||||
|         step={0.1} | ||||
|         sx={{ | ||||
|           color: "#fff", | ||||
|           "& .MuiSlider-thumb": { | ||||
|             backgroundColor: "#fff", | ||||
|           }, | ||||
|           "& .MuiSlider-track": { | ||||
|             backgroundColor: "#fff", | ||||
|           }, | ||||
|           "& .MuiSlider-rail": { | ||||
|             backgroundColor: "#666", | ||||
|           }, | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         type="number" | ||||
|         label="Текущий масштаб" | ||||
|         variant="filled" | ||||
|         value={Math.round(scale * SCALE_FACTOR * 100) / 100} | ||||
|         onChange={(e) => { | ||||
|           const newScale = Number(e.target.value); | ||||
|           if ( | ||||
|             !isNaN(newScale) && | ||||
|             newScale >= minScale && | ||||
|             newScale <= maxScale | ||||
|           ) { | ||||
|             setScaleAtCenter(newScale / SCALE_FACTOR); | ||||
|           } | ||||
|         }} | ||||
|         style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|         sx={{ | ||||
|           "& .MuiInputLabel-root": { | ||||
|             color: "#fff", | ||||
|           }, | ||||
|           "& .MuiInputBase-input": { | ||||
|             color: "#fff", | ||||
|           }, | ||||
|         }} | ||||
|         inputProps={{ | ||||
|           min: minScale, | ||||
|           max: maxScale, | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         type="number" | ||||
|         label="Поворот (в градусах)" | ||||
| @@ -181,11 +310,13 @@ export function RightSidebar() { | ||||
|           type="number" | ||||
|           label="Центр карты, широта" | ||||
|           variant="filled" | ||||
|           value={Math.round(localCenter.x * 100000) / 100000} | ||||
|           value={Math.round(localCenter.x * 1000) / 1000} | ||||
|           onChange={(e) => { | ||||
|             setIsUserEditing(true); | ||||
|             setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); | ||||
|             pan({ x: Number(e.target.value), y: localCenter.y }); | ||||
|           }} | ||||
|           onBlur={() => setIsUserEditing(false)} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -195,16 +326,21 @@ export function RightSidebar() { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|           }} | ||||
|           inputProps={{ | ||||
|             step: 0.001, | ||||
|           }} | ||||
|         /> | ||||
|         <TextField | ||||
|           type="number" | ||||
|           label="Центр карты, высота" | ||||
|           variant="filled" | ||||
|           value={Math.round(localCenter.y * 100000) / 100000} | ||||
|           value={Math.round(localCenter.y * 1000) / 1000} | ||||
|           onChange={(e) => { | ||||
|             setIsUserEditing(true); | ||||
|             setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); | ||||
|             pan({ x: localCenter.x, y: Number(e.target.value) }); | ||||
|           }} | ||||
|           onBlur={() => setIsUserEditing(false)} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -214,6 +350,9 @@ export function RightSidebar() { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|           }} | ||||
|           inputProps={{ | ||||
|             step: 0.001, | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|  | ||||
| @@ -221,8 +360,14 @@ export function RightSidebar() { | ||||
|         variant="contained" | ||||
|         color="secondary" | ||||
|         sx={{ mt: 2 }} | ||||
|         onClick={() => { | ||||
|           saveChanges(); | ||||
|         onClick={async () => { | ||||
|           try { | ||||
|             await saveChanges(); | ||||
|             toast.success("Изменения сохранены"); | ||||
|           } catch (error) { | ||||
|             console.error(error); | ||||
|             toast.error("Ошибка при сохранении изменений"); | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         Сохранить изменения | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { SightData } from "./types"; | ||||
| import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js"; | ||||
| import { Assets, FederatedMouseEvent, Texture } from "pixi.js"; | ||||
|  | ||||
| import { SIGHT_SIZE, UP_SCALE } from "./Constants"; | ||||
| import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||
| @@ -12,19 +12,21 @@ interface SightProps { | ||||
|   id: number; | ||||
| } | ||||
|  | ||||
| export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
| export const Sight = ({ sight, id }: Readonly<SightProps>) => { | ||||
|   const { rotation, scale } = useTransform(); | ||||
|   const { setSightCoordinates } = useMapData(); | ||||
|   const { setSightCoordinates, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   const [position, setPosition] = useState( | ||||
|     coordinatesToLocal(sight.latitude, sight.longitude) | ||||
|   ); | ||||
|   const [isDragging, setIsDragging] = useState(false); | ||||
|   const [isPointerDown, setIsPointerDown] = useState(false); | ||||
|   const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|   const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||
|  | ||||
|   const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|     setIsDragging(true); | ||||
|     setIsPointerDown(true); | ||||
|     setIsDragging(false); | ||||
|     setStartPosition({ | ||||
|       x: position.x, | ||||
|       y: position.y, | ||||
| @@ -37,7 +39,18 @@ export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|   const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|     if (!isDragging) return; | ||||
|     if (!isPointerDown) return; | ||||
|  | ||||
|     if (!isDragging) { | ||||
|       const dx = e.globalX - startMousePosition.x; | ||||
|       const dy = e.globalY - startMousePosition.y; | ||||
|       if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { | ||||
|         setIsDragging(true); | ||||
|       } else { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE; | ||||
|     const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE; | ||||
|     const cos = Math.cos(rotation); | ||||
| @@ -53,30 +66,33 @@ export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
|   }; | ||||
|  | ||||
|   const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|     setIsPointerDown(false); | ||||
|  | ||||
|     // Если не было перетаскивания, то это клик | ||||
|     if (!isDragging) { | ||||
|       setSelectedSight(sight); | ||||
|     } | ||||
|  | ||||
|     setIsDragging(false); | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|  | ||||
|   const [texture, setTexture] = useState(Texture.EMPTY); | ||||
|   useEffect(() => { | ||||
|     if (texture === Texture.EMPTY) { | ||||
|       Assets.load("/SightIcon.png").then((result) => { | ||||
|         setTexture(result); | ||||
|       }); | ||||
|     } | ||||
|   }, [texture]); | ||||
|     Assets.load("/SightIcon.png").then(setTexture); | ||||
|   }, []); | ||||
|  | ||||
|   function draw(g: Graphics) { | ||||
|     g.clear(); | ||||
|     g.circle(0, 0, 20); | ||||
|     g.fill({ color: "#000" }); // Fill circle with primary color | ||||
|   } | ||||
|   useEffect(() => {}, [id, sight.latitude, sight.longitude]); | ||||
|  | ||||
|   if (!sight) { | ||||
|     console.error("sight is null"); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   // Компенсируем масштаб для сохранения постоянного размера | ||||
|   const compensatedSize = SIGHT_SIZE / scale; | ||||
|   const compensatedFontSize = 24 / scale; | ||||
|  | ||||
|   return ( | ||||
|     <pixiContainer | ||||
|       rotation={-rotation} | ||||
| @@ -86,22 +102,34 @@ export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
|       onGlobalPointerMove={handlePointerMove} | ||||
|       onPointerUp={handlePointerUp} | ||||
|       onPointerUpOutside={handlePointerUp} | ||||
|       x={position.x * UP_SCALE - SIGHT_SIZE / 2} // Offset by half width to center | ||||
|       y={position.y * UP_SCALE - SIGHT_SIZE / 2} // Offset by half height to center | ||||
|       x={position.x * UP_SCALE - SIGHT_SIZE / 2} | ||||
|       y={position.y * UP_SCALE - SIGHT_SIZE / 2} | ||||
|     > | ||||
|       <pixiSprite texture={texture} width={SIGHT_SIZE} height={SIGHT_SIZE} /> | ||||
|       <pixiGraphics draw={draw} x={SIGHT_SIZE} y={0} /> | ||||
|       <pixiSprite | ||||
|         texture={texture} | ||||
|         width={compensatedSize} | ||||
|         height={compensatedSize} | ||||
|       /> | ||||
|       <pixiGraphics | ||||
|         draw={(g) => { | ||||
|           g.clear(); | ||||
|           g.circle(0, 0, 20 / scale); | ||||
|           g.fill({ color: "#000" }); | ||||
|         }} | ||||
|         x={compensatedSize} | ||||
|         y={0} | ||||
|       /> | ||||
|       <pixiText | ||||
|         text={`${id + 1}`} | ||||
|         x={SIGHT_SIZE + 1} | ||||
|         x={compensatedSize + 1 / scale} | ||||
|         y={0} | ||||
|         anchor={0.5} | ||||
|         style={{ | ||||
|           fontSize: 24, | ||||
|           fontSize: compensatedFontSize, | ||||
|           fontWeight: "bold", | ||||
|           fill: "#ffffff", | ||||
|         }} | ||||
|       /> | ||||
|     </pixiContainer> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
|   | ||||
							
								
								
									
										60
									
								
								src/pages/Route/route-preview/SightInfoWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/pages/Route/route-preview/SightInfoWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import { Box, Typography, IconButton } from "@mui/material"; | ||||
| import { Close } from "@mui/icons-material"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
|  | ||||
| export function SightInfoWidget() { | ||||
|   const { selectedSight, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   if (!selectedSight) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         position: "absolute", | ||||
|         bottom: 16, | ||||
|         left: "50%", | ||||
|         transform: "translateX(-50%)", | ||||
|         backgroundColor: "rgba(0, 0, 0, 0.9)", | ||||
|         color: "white", | ||||
|         padding: "12px 16px", | ||||
|         borderRadius: "4px", | ||||
|         minWidth: 250, | ||||
|         maxWidth: 400, | ||||
|         backdropFilter: "blur(10px)", | ||||
|         border: "1px solid rgba(255, 255, 255, 0.2)", | ||||
|         zIndex: 1000, | ||||
|         boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", | ||||
|       }} | ||||
|     > | ||||
|       <Box | ||||
|         sx={{ | ||||
|           display: "flex", | ||||
|           justifyContent: "space-between", | ||||
|           alignItems: "flex-start", | ||||
|           mb: 1, | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}> | ||||
|           {selectedSight.name} | ||||
|         </Typography> | ||||
|         <IconButton | ||||
|           size="small" | ||||
|           onClick={() => setSelectedSight(undefined)} | ||||
|           sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }} | ||||
|         > | ||||
|           <Close fontSize="small" /> | ||||
|         </IconButton> | ||||
|       </Box> | ||||
|  | ||||
|       <Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}> | ||||
|         {selectedSight.address} | ||||
|       </Typography> | ||||
|  | ||||
|       <Typography variant="caption" sx={{ color: "#999" }}> | ||||
|         Город: {selectedSight.city} | ||||
|       </Typography> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,4 +1,8 @@ | ||||
| import { FederatedMouseEvent, Graphics } from "pixi.js"; | ||||
| import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| // --- Заглушки для зависимостей (замените на ваши реальные импорты) --- | ||||
| import { | ||||
|   BACKGROUND_COLOR, | ||||
|   PATH_COLOR, | ||||
| @@ -7,140 +11,565 @@ import { | ||||
|   UP_SCALE, | ||||
| } from "./Constants"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { useCallback, useState } from "react"; | ||||
| import { StationData } from "./types"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { coordinatesToLocal } from "./utils"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { languageStore } from "@shared"; | ||||
| // --- Конец заглушек --- | ||||
|  | ||||
| // --- Декларации для react-pixi --- | ||||
| // (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi) | ||||
| declare const pixiContainer: any; | ||||
| declare const pixiGraphics: any; | ||||
| declare const pixiText: any; | ||||
|  | ||||
| // --- Типы --- | ||||
| type HorizontalAlign = "left" | "center" | "right"; | ||||
| type VerticalAlign = "top" | "center" | "bottom"; | ||||
| type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; | ||||
| type LabelAlign = "left" | "center" | "right"; | ||||
|  | ||||
| // --- Утилиты --- | ||||
|  | ||||
| /** | ||||
|  * Преобразует текстовое позиционирование в anchor координаты. | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Получает координату anchor.x из типа выравнивания. | ||||
|  */ | ||||
|  | ||||
| // --- Интерфейсы пропсов --- | ||||
|  | ||||
| interface StationProps { | ||||
|   station: StationData; | ||||
|   ruLabel: string | null; | ||||
|   anchorPoint?: { x: number; y: number }; | ||||
|   /** Anchor для всего блока с текстом. По умолчанию: `"right center"` */ | ||||
|   labelBlockAnchor?: TextAlign | { x: number; y: number }; | ||||
|   /** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */ | ||||
|   labelAlign?: LabelAlign; | ||||
|   /** Callback для изменения внутреннего выравнивания */ | ||||
|   onLabelAlignChange?: (align: LabelAlign) => void; | ||||
|   /** Callback для отслеживания наведения на текст */ | ||||
|   onTextHover?: (isHovered: boolean) => void; | ||||
| } | ||||
|  | ||||
| export const Station = observer( | ||||
|   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||
|     const draw = useCallback((g: Graphics) => { | ||||
| interface LabelAlignmentControlProps { | ||||
|   scale: number; | ||||
|   currentAlign: LabelAlign; | ||||
|   onAlignChange: (align: LabelAlign) => void; | ||||
|   onPointerOver: () => void; | ||||
|   onPointerOut: () => void; | ||||
|   onControlPointerEnter: () => void; | ||||
|   onControlPointerLeave: () => void; | ||||
| } | ||||
|  | ||||
| interface StationLabelProps | ||||
|   extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {} | ||||
|  | ||||
| const getAnchorFromOffset = ( | ||||
|   offsetX: number, | ||||
|   offsetY: number | ||||
| ): { x: number; y: number } => { | ||||
|   if (offsetX === 0 && offsetY === 0) { | ||||
|     return { x: 0.5, y: 0.5 }; | ||||
|   } | ||||
|  | ||||
|   const length = Math.hypot(offsetX, offsetY); | ||||
|   const nx = offsetX / length; | ||||
|   const ny = offsetY / length; | ||||
|  | ||||
|   return { x: (1 - nx) / 2, y: (1 - ny) / 2 }; | ||||
| }; | ||||
|  | ||||
| // ========================================================================= | ||||
| // Компонент: Панель управления выравниванием в стиле УрФУ | ||||
| // ========================================================================= | ||||
|  | ||||
| const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({ | ||||
|   scale, | ||||
|   currentAlign, | ||||
|   onAlignChange, | ||||
|  | ||||
|   onControlPointerEnter, | ||||
|   onControlPointerLeave, | ||||
| }) => { | ||||
|   const controlHeight = 50 / scale; | ||||
|   const controlWidth = 200 / scale; | ||||
|   const fontSize = 18 / scale; | ||||
|   const borderRadius = 8 / scale; | ||||
|   const compensatedRuFontSize = (26 * 0.75) / scale; | ||||
|   const buttonWidth = controlWidth / 3; | ||||
|   const strokeWidth = 2 / scale; | ||||
|  | ||||
|   const drawBg = useCallback( | ||||
|     (g: Graphics) => { | ||||
|       g.clear(); | ||||
|  | ||||
|       // Основной фон с градиентом | ||||
|       g.roundRect( | ||||
|         -controlWidth / 2, | ||||
|         0, | ||||
|         controlWidth, | ||||
|         controlHeight, | ||||
|         borderRadius | ||||
|       ); | ||||
|       g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ | ||||
|  | ||||
|       // Тонкая рамка | ||||
|       g.roundRect( | ||||
|         -controlWidth / 2, | ||||
|         0, | ||||
|         controlWidth, | ||||
|         controlHeight, | ||||
|         borderRadius | ||||
|       ); | ||||
|       g.stroke({ color: "#333333", width: strokeWidth }); | ||||
|  | ||||
|       // Разделители между кнопками | ||||
|       for (let i = 1; i < 3; i++) { | ||||
|         const x = -controlWidth / 2 + buttonWidth * i; | ||||
|         g.moveTo(x, strokeWidth); | ||||
|         g.lineTo(x, controlHeight - strokeWidth); | ||||
|         g.stroke({ color: "#333333", width: strokeWidth }); | ||||
|       } | ||||
|     }, | ||||
|     [controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth] | ||||
|   ); | ||||
|  | ||||
|   const drawButtonHighlight = useCallback( | ||||
|     (g: Graphics, index: number, isActive: boolean) => { | ||||
|       g.clear(); | ||||
|  | ||||
|       if (isActive) { | ||||
|         const x = -controlWidth / 2 + buttonWidth * index; | ||||
|         g.roundRect( | ||||
|           x + strokeWidth, | ||||
|           strokeWidth, | ||||
|           buttonWidth - strokeWidth * 2, | ||||
|           controlHeight - strokeWidth * 2, | ||||
|           borderRadius / 2 | ||||
|         ); | ||||
|         g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ | ||||
|       } | ||||
|     }, | ||||
|     [controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius] | ||||
|   ); | ||||
|  | ||||
|   const getTextStyle = (isActive: boolean) => ({ | ||||
|     fontSize, | ||||
|     fontWeight: isActive ? ("bold" as const) : ("normal" as const), | ||||
|     fill: isActive ? "#ffffff" : "#cccccc", | ||||
|     fontFamily: "Arial, sans-serif", | ||||
|   }); | ||||
|  | ||||
|   const alignOptions = [ | ||||
|     { key: "left" as const, label: "Left" }, | ||||
|     { key: "center" as const, label: "Center" }, | ||||
|     { key: "right" as const, label: "Right" }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <pixiContainer | ||||
|       position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }} | ||||
|       zIndex={999999999999999999} | ||||
|       eventMode="static" | ||||
|       onPointerOver={(e: FederatedMouseEvent) => { | ||||
|         e.stopPropagation(); | ||||
|         onControlPointerEnter(); | ||||
|       }} | ||||
|       onPointerOut={(e: FederatedMouseEvent) => { | ||||
|         e.stopPropagation(); | ||||
|         onControlPointerLeave(); | ||||
|       }} | ||||
|       onPointerDown={(e: FederatedMouseEvent) => { | ||||
|         e.stopPropagation(); | ||||
|       }} | ||||
|     > | ||||
|       {/* Основной фон */} | ||||
|       <pixiGraphics draw={drawBg} /> | ||||
|  | ||||
|       {/* Кнопки с подсветкой */} | ||||
|       {alignOptions.map((option, index) => ( | ||||
|         <pixiContainer key={option.key}> | ||||
|           {/* Подсветка активной кнопки */} | ||||
|           <pixiGraphics | ||||
|             draw={(g: Graphics) => | ||||
|               drawButtonHighlight(g, index, option.key === currentAlign) | ||||
|             } | ||||
|           /> | ||||
|  | ||||
|           {/* Текст кнопки */} | ||||
|           <pixiText | ||||
|             text={option.label} | ||||
|             anchor={{ x: 0.5, y: 0.5 }} | ||||
|             position={{ | ||||
|               x: -controlWidth / 2 + buttonWidth * (index + 0.5), | ||||
|               y: controlHeight / 2, | ||||
|             }} | ||||
|             style={getTextStyle(option.key === currentAlign)} | ||||
|             eventMode="static" | ||||
|             cursor="pointer" | ||||
|             onClick={(e: FederatedMouseEvent) => { | ||||
|               e.stopPropagation(); | ||||
|               onAlignChange(option.key); | ||||
|             }} | ||||
|             onPointerDown={(e: FederatedMouseEvent) => { | ||||
|               e.stopPropagation(); | ||||
|               onAlignChange(option.key); | ||||
|             }} | ||||
|             onPointerOver={(e: FederatedMouseEvent) => { | ||||
|               e.stopPropagation(); | ||||
|               onControlPointerEnter(); | ||||
|             }} | ||||
|           /> | ||||
|         </pixiContainer> | ||||
|       ))} | ||||
|     </pixiContainer> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // ========================================================================= | ||||
| // Компонент: Метка Станции (с логикой) | ||||
| // ========================================================================= | ||||
|  | ||||
| const StationLabel = observer( | ||||
|   ({ | ||||
|     station, | ||||
|     ruLabel, | ||||
|  | ||||
|     labelAlign: labelAlignProp = "center", | ||||
|     onLabelAlignChange, | ||||
|     onTextHover, | ||||
|   }: Readonly<StationLabelProps>) => { | ||||
|     const { language } = languageStore; | ||||
|     const { rotation, scale } = useTransform(); | ||||
|     const { setStationOffset, setStationAlign } = useMapData(); | ||||
|  | ||||
|     const [position, setPosition] = useState({ x: 0, y: 0 }); | ||||
|     const [isDragging, setIsDragging] = useState(false); | ||||
|     const [isPointerDown, setIsPointerDown] = useState(false); | ||||
|     const [isHovered, setIsHovered] = useState(false); | ||||
|     const [isControlHovered, setIsControlHovered] = useState(false); | ||||
|     const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp); | ||||
|     const [ruLabelWidth, setRuLabelWidth] = useState(0); | ||||
|  | ||||
|     const dragStartPos = useRef({ x: 0, y: 0 }); | ||||
|     const mouseStartPos = useRef({ x: 0, y: 0 }); | ||||
|     const hideTimer = useRef<NodeJS.Timeout | null>(null); | ||||
|     const ruLabelRef = useRef<any>(null); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       return () => { | ||||
|         if (hideTimer.current) { | ||||
|           clearTimeout(hideTimer.current); | ||||
|         } | ||||
|       }; | ||||
|     }, []); | ||||
|  | ||||
|     const handlePointerEnter = () => { | ||||
|       if (hideTimer.current) { | ||||
|         clearTimeout(hideTimer.current); | ||||
|         hideTimer.current = null; | ||||
|       } | ||||
|       setIsHovered(true); | ||||
|       onTextHover?.(true); // Call the callback to indicate text is hovered | ||||
|     }; | ||||
|  | ||||
|     const handleControlPointerEnter = () => { | ||||
|       // Дополнительная обработка для панели управления | ||||
|       if (hideTimer.current) { | ||||
|         clearTimeout(hideTimer.current); | ||||
|         hideTimer.current = null; | ||||
|       } | ||||
|       setIsControlHovered(true); | ||||
|       setIsHovered(true); | ||||
|       onTextHover?.(true); // Call the callback to indicate text/control is hovered | ||||
|     }; | ||||
|  | ||||
|     const handleControlPointerLeave = () => { | ||||
|       setIsControlHovered(false); | ||||
|       // Если курсор не над основным контейнером, скрываем панель через некоторое время | ||||
|       if (!isHovered) { | ||||
|         hideTimer.current = setTimeout(() => { | ||||
|           setIsHovered(false); | ||||
|           onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered | ||||
|         }, 0); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const handlePointerLeave = () => { | ||||
|       // Увеличиваем время до скрытия панели и добавляем проверку | ||||
|       hideTimer.current = setTimeout(() => { | ||||
|         setIsHovered(false); | ||||
|         // Если курсор не над панелью управления, скрываем и её | ||||
|         if (!isControlHovered) { | ||||
|           setIsControlHovered(false); | ||||
|         } | ||||
|         onTextHover?.(false); // Call the callback to indicate text is no longer hovered | ||||
|       }, 100); // Увеличиваем время до скрытия панели | ||||
|     }; | ||||
|  | ||||
|     useEffect(() => { | ||||
|       setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); | ||||
|     }, [station.offset_x, station.offset_y, station.id]); | ||||
|  | ||||
|     // Функция для конвертации числового align в строковый | ||||
|     const convertNumericAlign = (align: number): LabelAlign => { | ||||
|       switch (align) { | ||||
|         case 0: | ||||
|           return "left"; | ||||
|         case 1: | ||||
|           return "center"; | ||||
|         case 2: | ||||
|           return "right"; | ||||
|         default: | ||||
|           return "center"; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     // Функция для конвертации строкового align в числовой | ||||
|     const convertStringAlign = (align: LabelAlign): number => { | ||||
|       switch (align) { | ||||
|         case "left": | ||||
|           return 0; | ||||
|         case "center": | ||||
|           return 1; | ||||
|         case "right": | ||||
|           return 2; | ||||
|         default: | ||||
|           return 1; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     useEffect(() => { | ||||
|       setCurrentLabelAlign(convertNumericAlign(station.align ?? 1)); | ||||
|     }, [station.align]); | ||||
|  | ||||
|     if (!station) return null; | ||||
|  | ||||
|     const coordinates = coordinatesToLocal(station.latitude, station.longitude); | ||||
|     const compensatedRuFontSize = (26 * 0.75) / scale; | ||||
|     const compensatedNameFontSize = (16 * 0.75) / scale; | ||||
|  | ||||
|     // Измеряем ширину верхнего лейбла | ||||
|     useEffect(() => { | ||||
|       if (ruLabelRef.current && ruLabel) { | ||||
|         setRuLabelWidth(ruLabelRef.current.width); | ||||
|       } | ||||
|     }, [ruLabel, compensatedRuFontSize]); | ||||
|  | ||||
|     const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|       setIsPointerDown(true); | ||||
|       setIsDragging(false); | ||||
|       dragStartPos.current = { ...position }; | ||||
|       mouseStartPos.current = { x: e.global.x, y: e.global.y }; | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|       if (!isPointerDown) return; | ||||
|  | ||||
|       if (!isDragging) { | ||||
|         const dx = e.global.x - mouseStartPos.current.x; | ||||
|         const dy = e.global.y - mouseStartPos.current.y; | ||||
|         if (Math.hypot(dx, dy) > 3) setIsDragging(true); | ||||
|         else return; | ||||
|       } | ||||
|  | ||||
|       const dx_screen = e.global.x - mouseStartPos.current.x; | ||||
|       const dy_screen = e.global.y - mouseStartPos.current.y; | ||||
|  | ||||
|       const newPosition = { | ||||
|         x: dragStartPos.current.x + dx_screen, | ||||
|         y: dragStartPos.current.y + dy_screen, | ||||
|       }; | ||||
|  | ||||
|       // Проверяем, изменилась ли позиция | ||||
|       if ( | ||||
|         Math.abs(newPosition.x - position.x) > 0.01 || | ||||
|         Math.abs(newPosition.y - position.y) > 0.01 | ||||
|       ) { | ||||
|         setPosition(newPosition); | ||||
|         setStationOffset(station.id, newPosition.x, newPosition.y); | ||||
|       } | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|       setIsPointerDown(false); | ||||
|       setTimeout(() => setIsDragging(false), 50); | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handleAlignChange = async (align: LabelAlign) => { | ||||
|       setCurrentLabelAlign(align); | ||||
|       onLabelAlignChange?.(align); | ||||
|       // Сохраняем в стор | ||||
|       const numericAlign = convertStringAlign(align); | ||||
|       setStationAlign(station.id, numericAlign); | ||||
|     }; | ||||
|  | ||||
|     const dynamicAnchor = useMemo( | ||||
|       () => getAnchorFromOffset(position.x, position.y), | ||||
|       [position.x, position.y] | ||||
|     ); | ||||
|  | ||||
|     // Функция для расчета позиции нижнего лейбла относительно ширины верхнего | ||||
|     const getSecondLabelPosition = (): number => { | ||||
|       if (!ruLabelWidth) return 0; | ||||
|  | ||||
|       switch (currentLabelAlign) { | ||||
|         case "left": | ||||
|           // Позиционируем относительно левого края верхнего текста | ||||
|           return -ruLabelWidth / 2; | ||||
|         case "center": | ||||
|           // Центрируем относительно центра верхнего текста | ||||
|           return 0; | ||||
|         case "right": | ||||
|           // Позиционируем относительно правого края верхнего текста | ||||
|           return ruLabelWidth / 2; | ||||
|         default: | ||||
|           return 0; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     // Функция для расчета anchor нижнего лейбла | ||||
|     const getSecondLabelAnchor = (): number => { | ||||
|       switch (currentLabelAlign) { | ||||
|         case "left": | ||||
|           return 0; // anchor.x = 0 (левый край) | ||||
|         case "center": | ||||
|           return 0.5; // anchor.x = 0.5 (центр) | ||||
|         case "right": | ||||
|           return 1; // anchor.x = 1 (правый край) | ||||
|         default: | ||||
|           return 0.5; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|       <pixiContainer | ||||
|         x={coordinates.x * UP_SCALE} | ||||
|         y={coordinates.y * UP_SCALE} | ||||
|         rotation={-rotation} | ||||
|         zIndex={isHovered || isControlHovered ? 1000 : 0} | ||||
|         eventMode="static" | ||||
|         interactive | ||||
|         cursor={isDragging ? "grabbing" : "grab"} | ||||
|         onPointerOver={handlePointerEnter} | ||||
|         onPointerOut={handlePointerLeave} | ||||
|         onPointerDown={handlePointerDown} | ||||
|         onPointerUp={handlePointerUp} | ||||
|         onPointerUpOutside={handlePointerUp} | ||||
|         onGlobalPointerMove={handlePointerMove} | ||||
|       > | ||||
|         <pixiContainer | ||||
|           position={{ | ||||
|             x: | ||||
|               (position.x + Math.cos(Math.atan2(position.y, position.x))) / | ||||
|               scale, | ||||
|             y: | ||||
|               (position.y + Math.sin(Math.atan2(position.y, position.x))) / | ||||
|               scale, | ||||
|           }} | ||||
|           anchor={dynamicAnchor} | ||||
|           zIndex={isHovered || isControlHovered ? 1000 : 0} | ||||
|         > | ||||
|           {ruLabel && ( | ||||
|             <pixiText | ||||
|               ref={ruLabelRef} | ||||
|               text={ruLabel} | ||||
|               position={{ x: 0, y: 0 }} | ||||
|               anchor={{ x: 0.5, y: 0.5 }} | ||||
|               style={{ | ||||
|                 fontSize: compensatedRuFontSize, | ||||
|                 fontWeight: "bold", | ||||
|                 fill: "#ffffff", | ||||
|               }} | ||||
|             /> | ||||
|           )} | ||||
|           {station.name && language !== "ru" && ruLabel && ( | ||||
|             <pixiText | ||||
|               text={station.name} | ||||
|               position={{ | ||||
|                 x: getSecondLabelPosition(), | ||||
|                 y: compensatedRuFontSize * 1.1, | ||||
|               }} | ||||
|               anchor={{ x: getSecondLabelAnchor(), y: 0.5 }} | ||||
|               style={{ | ||||
|                 fontSize: compensatedNameFontSize, | ||||
|                 fontWeight: "bold", | ||||
|                 fill: "#CCCCCC", | ||||
|               }} | ||||
|             /> | ||||
|           )} | ||||
|           {(isHovered || isControlHovered) && !isDragging && ( | ||||
|             <LabelAlignmentControl | ||||
|               scale={scale} | ||||
|               currentAlign={currentLabelAlign} | ||||
|               onAlignChange={handleAlignChange} | ||||
|               onPointerOver={handlePointerEnter} | ||||
|               onPointerOut={handlePointerLeave} | ||||
|               onControlPointerEnter={handleControlPointerEnter} | ||||
|               onControlPointerLeave={handleControlPointerLeave} | ||||
|             /> | ||||
|           )} | ||||
|         </pixiContainer> | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // ========================================================================= | ||||
| // Главный экспортируемый компонент: Станция | ||||
| // ========================================================================= | ||||
|  | ||||
| export const Station = ({ | ||||
|   station, | ||||
|   ruLabel, | ||||
|  | ||||
|   labelAlign, | ||||
|   onLabelAlignChange, | ||||
| }: Readonly<StationProps>) => { | ||||
|   const [isTextHovered, setIsTextHovered] = useState(false); | ||||
|  | ||||
|   const draw = useCallback( | ||||
|     (g: Graphics) => { | ||||
|       g.clear(); | ||||
|       const coordinates = coordinatesToLocal( | ||||
|         station.latitude, | ||||
|         station.longitude | ||||
|       ); | ||||
|       g.circle( | ||||
|         coordinates.x * UP_SCALE, | ||||
|         coordinates.y * UP_SCALE, | ||||
|         STATION_RADIUS | ||||
|       ); | ||||
|       g.fill({ color: PATH_COLOR }); | ||||
|       g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH }); | ||||
|     }, []); | ||||
|  | ||||
|     return ( | ||||
|       <pixiContainer> | ||||
|         <pixiGraphics draw={draw} /> | ||||
|         <StationLabel station={station} ruLabel={ruLabel} /> | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|       const radius = STATION_RADIUS; | ||||
|       const strokeWidth = STATION_OUTLINE_WIDTH; | ||||
|  | ||||
| export const StationLabel = observer( | ||||
|   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||
|     const { rotation, scale } = useTransform(); | ||||
|     const { setStationOffset } = useMapData(); | ||||
|       g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius); | ||||
|  | ||||
|     const [position, setPosition] = useState({ | ||||
|       x: station.offset_x, | ||||
|       y: station.offset_y, | ||||
|     }); | ||||
|     const [isDragging, setIsDragging] = useState(false); | ||||
|     const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|     const [startMousePosition, setStartMousePosition] = useState({ | ||||
|       x: 0, | ||||
|       y: 0, | ||||
|     }); | ||||
|       // Change fill color when text is hovered | ||||
|       if (isTextHovered) { | ||||
|         g.fill({ color: 0x00aaff }); // Highlight color when hovered | ||||
|         g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered | ||||
|       } else { | ||||
|         g.fill({ color: PATH_COLOR }); | ||||
|         g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth }); | ||||
|       } | ||||
|     }, | ||||
|     [station.latitude, station.longitude, isTextHovered] | ||||
|   ); | ||||
|  | ||||
|     if (!station) { | ||||
|       console.error("station is null"); | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|       setIsDragging(true); | ||||
|       setStartPosition({ | ||||
|         x: position.x, | ||||
|         y: position.y, | ||||
|       }); | ||||
|       setStartMousePosition({ | ||||
|         x: e.globalX, | ||||
|         y: e.globalY, | ||||
|       }); | ||||
|  | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|     const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|       if (!isDragging) return; | ||||
|       const dx = e.globalX - startMousePosition.x; | ||||
|       const dy = e.globalY - startMousePosition.y; | ||||
|       const newPosition = { | ||||
|         x: startPosition.x + dx, | ||||
|         y: startPosition.y + dy, | ||||
|       }; | ||||
|       setPosition(newPosition); | ||||
|       setStationOffset(station.id, newPosition.x, newPosition.y); | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|       setIsDragging(false); | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|     const coordinates = coordinatesToLocal(station.latitude, station.longitude); | ||||
|  | ||||
|     return ( | ||||
|       <pixiContainer | ||||
|         eventMode="static" | ||||
|         interactive | ||||
|         onPointerDown={handlePointerDown} | ||||
|         onGlobalPointerMove={handlePointerMove} | ||||
|         onPointerUp={handlePointerUp} | ||||
|         onPointerUpOutside={handlePointerUp} | ||||
|         width={48} | ||||
|         height={48} | ||||
|         x={coordinates.x * UP_SCALE} | ||||
|         y={coordinates.y * UP_SCALE} | ||||
|         rotation={-rotation} | ||||
|       > | ||||
|         <pixiText | ||||
|           anchor={{ x: 1, y: 0.5 }} | ||||
|           text={station.name} | ||||
|           position={{ | ||||
|             x: position.x / scale + 24, | ||||
|             y: position.y / scale, | ||||
|           }} | ||||
|           style={{ | ||||
|             fontSize: 26, | ||||
|             fontWeight: "bold", | ||||
|             fill: "#ffffff", | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
|         {ruLabel && ( | ||||
|           <pixiText | ||||
|             anchor={{ x: 1, y: -1 }} | ||||
|             text={ruLabel} | ||||
|             position={{ | ||||
|               x: position.x / scale + 24, | ||||
|               y: position.y / scale, | ||||
|             }} | ||||
|             style={{ | ||||
|               fontSize: 16, | ||||
|               fontWeight: "bold", | ||||
|               fill: "#CCCCCC", | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|   return ( | ||||
|     <pixiContainer zIndex={isTextHovered ? 1000 : 0}> | ||||
|       <pixiGraphics draw={draw} /> | ||||
|       <StationLabel | ||||
|         station={station} | ||||
|         ruLabel={ruLabel} | ||||
|         labelAlign={labelAlign} | ||||
|         onLabelAlignChange={onLabelAlignChange} | ||||
|         onTextHover={setIsTextHovered} | ||||
|       /> | ||||
|     </pixiContainer> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -26,9 +26,12 @@ const TransformContext = createContext<{ | ||||
|     rotationDegrees?: number, | ||||
|     scale?: number | ||||
|   ) => void; | ||||
|   setScaleOnly: (newScale: number) => void; | ||||
|   setScaleWithoutMovingCenter: (newScale: number) => void; | ||||
|   setScreenCenter: React.Dispatch< | ||||
|     React.SetStateAction<{ x: number; y: number } | undefined> | ||||
|   >; | ||||
|   setScaleAtCenter: (newScale: number) => void; | ||||
| }>({ | ||||
|   position: { x: 0, y: 0 }, | ||||
|   scale: 1, | ||||
| @@ -41,7 +44,10 @@ const TransformContext = createContext<{ | ||||
|   localToScreen: () => ({ x: 0, y: 0 }), | ||||
|   rotateToAngle: () => {}, | ||||
|   setTransform: () => {}, | ||||
|   setScaleOnly: () => {}, | ||||
|   setScaleWithoutMovingCenter: () => {}, | ||||
|   setScreenCenter: () => {}, | ||||
|   setScaleAtCenter: () => {}, | ||||
| }); | ||||
|  | ||||
| // Provider component | ||||
| @@ -136,8 +142,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|         useScale !== undefined ? useScale / SCALE_FACTOR : scale; | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|  | ||||
|       console.log("center", center.x, center.y); | ||||
|  | ||||
|       const newPosition = { | ||||
|         x: -latitude * UP_SCALE * selectedScale, | ||||
|         y: -longitude * UP_SCALE * selectedScale, | ||||
| @@ -160,6 +164,37 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|     [rotation, scale, screenCenter] | ||||
|   ); | ||||
|  | ||||
|   const setScaleAtCenter = useCallback( | ||||
|     (newScale: number) => { | ||||
|       if (scale === newScale) return; | ||||
|  | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|  | ||||
|       const actualZoomFactor = newScale / scale; | ||||
|  | ||||
|       const newPosition = { | ||||
|         x: position.x + (center.x - position.x) * (1 - actualZoomFactor), | ||||
|         y: position.y + (center.y - position.y) * (1 - actualZoomFactor), | ||||
|       }; | ||||
|  | ||||
|       setPosition(newPosition); | ||||
|       setScale(newScale); | ||||
|     }, | ||||
|     [position, scale, screenCenter] | ||||
|   ); | ||||
|  | ||||
|   const setScaleOnly = useCallback((newScale: number) => { | ||||
|     // Изменяем только масштаб, не трогая позицию и поворот | ||||
|     setScale(newScale); | ||||
|   }, []); | ||||
|  | ||||
|   const setScaleWithoutMovingCenter = useCallback( | ||||
|     (newScale: number) => { | ||||
|       setScale(newScale); | ||||
|     }, | ||||
|     [setScale] | ||||
|   ); | ||||
|  | ||||
|   const value = useMemo( | ||||
|     () => ({ | ||||
|       position, | ||||
| @@ -173,17 +208,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|       screenToLocal, | ||||
|       localToScreen, | ||||
|       setTransform, | ||||
|       setScaleOnly, | ||||
|       setScaleWithoutMovingCenter, | ||||
|       setScreenCenter, | ||||
|       setScaleAtCenter, | ||||
|     }), | ||||
|     [ | ||||
|       position, | ||||
|       scale, | ||||
|       rotation, | ||||
|       screenCenter, | ||||
|       setScale, | ||||
|       rotateToAngle, | ||||
|       screenToLocal, | ||||
|       localToScreen, | ||||
|       setTransform, | ||||
|       setScaleOnly, | ||||
|       setScaleWithoutMovingCenter, | ||||
|       setScreenCenter, | ||||
|       setScaleAtCenter, | ||||
|     ] | ||||
|   ); | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| import { Stack, Typography } from "@mui/material"; | ||||
| import { Stack, Typography, Box, IconButton } from "@mui/material"; | ||||
| import { Close } from "@mui/icons-material"; | ||||
| import { Landmark } from "lucide-react"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
|  | ||||
| export function Widgets() { | ||||
|   const { selectedSight, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   return ( | ||||
|     <Stack | ||||
|       direction="column" | ||||
| @@ -24,6 +29,8 @@ export function Widgets() { | ||||
|           Станция | ||||
|         </Typography> | ||||
|       </Stack> | ||||
|  | ||||
|       {/* Виджет выбранной достопримечательности (заменяет виджет погоды) */} | ||||
|       <Stack | ||||
|         bgcolor="primary.main" | ||||
|         width={223} | ||||
| @@ -31,12 +38,102 @@ export function Widgets() { | ||||
|         p={2} | ||||
|         m={2} | ||||
|         borderRadius={2} | ||||
|         alignItems="center" | ||||
|         justifyContent="center" | ||||
|         sx={{ | ||||
|           pointerEvents: "auto", | ||||
|           position: "relative", | ||||
|           overflow: "hidden", | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="h6" sx={{ color: "#fff" }}> | ||||
|           Погода | ||||
|         </Typography> | ||||
|         {selectedSight ? ( | ||||
|           <Box | ||||
|             sx={{ height: "100%", display: "flex", flexDirection: "column" }} | ||||
|           > | ||||
|             {/* Заголовок с кнопкой закрытия */} | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 display: "flex", | ||||
|                 justifyContent: "space-between", | ||||
|                 alignItems: "flex-start", | ||||
|                 mb: 1, | ||||
|               }} | ||||
|             > | ||||
|               <Box sx={{ display: "flex", gap: 0.5 }}> | ||||
|                 <Landmark size={16} className="shrink-0" /> | ||||
|                 <Typography | ||||
|                   variant="subtitle2" | ||||
|                   sx={{ color: "#fff", fontWeight: "bold" }} | ||||
|                 > | ||||
|                   {selectedSight.name} | ||||
|                 </Typography> | ||||
|               </Box> | ||||
|               <IconButton | ||||
|                 size="small" | ||||
|                 onClick={() => setSelectedSight(undefined)} | ||||
|                 sx={{ | ||||
|                   color: "#fff", | ||||
|                   p: 0, | ||||
|                   minWidth: 20, | ||||
|                   width: 20, | ||||
|                   height: 20, | ||||
|                   "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" }, | ||||
|                 }} | ||||
|               > | ||||
|                 <Close fontSize="small" /> | ||||
|               </IconButton> | ||||
|             </Box> | ||||
|  | ||||
|             {/* Описание достопримечательности */} | ||||
|             {selectedSight.address && ( | ||||
|               <Typography | ||||
|                 variant="caption" | ||||
|                 sx={{ | ||||
|                   color: "#fff", | ||||
|                   mb: 1, | ||||
|                   opacity: 0.9, | ||||
|                   lineHeight: 1.3, | ||||
|                   overflow: "hidden", | ||||
|                   textOverflow: "ellipsis", | ||||
|                   display: "-webkit-box", | ||||
|                   WebkitLineClamp: 3, | ||||
|                   WebkitBoxOrient: "vertical", | ||||
|                 }} | ||||
|               > | ||||
|                 {selectedSight.address} | ||||
|               </Typography> | ||||
|             )} | ||||
|  | ||||
|             {/* Город */} | ||||
|             {selectedSight.city && ( | ||||
|               <Typography | ||||
|                 variant="caption" | ||||
|                 sx={{ | ||||
|                   color: "#fff", | ||||
|                   opacity: 0.7, | ||||
|                   mt: "auto", | ||||
|                 }} | ||||
|               > | ||||
|                 Город: {selectedSight.city} | ||||
|               </Typography> | ||||
|             )} | ||||
|           </Box> | ||||
|         ) : ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               height: "100%", | ||||
|               display: "flex", | ||||
|               flexDirection: "column", | ||||
|               alignItems: "center", | ||||
|               gap: 5, | ||||
|               justifyContent: "center", | ||||
|               textAlign: "center", | ||||
|             }} | ||||
|           > | ||||
|             <Landmark size={32} /> | ||||
|             <Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}> | ||||
|               Выберите достопримечательность | ||||
|             </Typography> | ||||
|           </Box> | ||||
|         )} | ||||
|       </Stack> | ||||
|     </Stack> | ||||
|   ); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { useRef, useEffect, useState } from "react"; | ||||
|  | ||||
| import { Widgets } from "./Widgets"; | ||||
| import { Application, extend } from "@pixi/react"; | ||||
| import { | ||||
|   Container, | ||||
| @@ -14,16 +14,19 @@ import { MapDataProvider, useMapData } from "./MapDataContext"; | ||||
| import { TransformProvider, useTransform } from "./TransformContext"; | ||||
| import { InfiniteCanvas } from "./InfiniteCanvas"; | ||||
|  | ||||
| import { UP_SCALE } from "./Constants"; | ||||
| import { Station } from "./Station"; | ||||
| import { TravelPath } from "./TravelPath"; | ||||
| import { LeftSidebar } from "./LeftSidebar"; | ||||
| import { RightSidebar } from "./RightSidebar"; | ||||
| import { Widgets } from "./Widgets"; | ||||
|  | ||||
| import { coordinatesToLocal } from "./utils"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { languageStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Sight } from "./Sight"; | ||||
| import { SightData } from "./types"; | ||||
| import { Station } from "./Station"; | ||||
| import { UP_SCALE } from "./Constants"; | ||||
| import CircularProgress from "@mui/material/CircularProgress"; | ||||
|  | ||||
| extend({ | ||||
|   Container, | ||||
| @@ -34,17 +37,31 @@ extend({ | ||||
|   Text, | ||||
| }); | ||||
|  | ||||
| const Loading = () => { | ||||
|   const { isRouteLoading, isStationLoading, isSightLoading } = useMapData(); | ||||
|  | ||||
|   if (isRouteLoading || isStationLoading || isSightLoading) { | ||||
|     return ( | ||||
|       <div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]"> | ||||
|         <CircularProgress /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return null; | ||||
| }; | ||||
| export const RoutePreview = () => { | ||||
|   const { routeData, stationData, sightData } = useMapData(); | ||||
|   return ( | ||||
|     <MapDataProvider> | ||||
|       <TransformProvider> | ||||
|         <Stack direction="row" height="100vh" width="100vw" overflow="hidden"> | ||||
|           <LanguageSwitcher /> | ||||
|  | ||||
|           {routeData && stationData && sightData ? <LanguageSwitcher /> : null} | ||||
|           <Loading /> | ||||
|           <LeftSidebar /> | ||||
|           <Stack direction="row" flex={1} position="relative" height="100%"> | ||||
|             <Widgets /> | ||||
|             <RouteMap /> | ||||
|             <Widgets /> | ||||
|             <RightSidebar /> | ||||
|           </Stack> | ||||
|         </Stack> | ||||
| @@ -55,15 +72,27 @@ export const RoutePreview = () => { | ||||
|  | ||||
| export const RouteMap = observer(() => { | ||||
|   const { language } = languageStore; | ||||
|   const { setPosition, screenToLocal, setTransform, screenCenter } = | ||||
|     useTransform(); | ||||
|   const { routeData, stationData, sightData, originalRouteData } = useMapData(); | ||||
|   console.log(stationData); | ||||
|   const { setPosition, setTransform, screenCenter } = useTransform(); | ||||
|   const { | ||||
|     routeData, | ||||
|     stationData, | ||||
|     sightData, | ||||
|     originalRouteData, | ||||
|     originalSightData, | ||||
|   } = useMapData(); | ||||
|  | ||||
|   const [points, setPoints] = useState<{ x: number; y: number }[]>([]); | ||||
|   const [isSetup, setIsSetup] = useState(false); | ||||
|  | ||||
|   const parentRef = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     document.body.style.overflow = "hidden"; | ||||
|     return () => { | ||||
|       document.body.style.overflow = "auto"; | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (originalRouteData) { | ||||
|       const path = originalRouteData?.path; | ||||
| @@ -131,13 +160,13 @@ export const RouteMap = observer(() => { | ||||
|   ]); | ||||
|  | ||||
|   if (!routeData || !stationData || !sightData) { | ||||
|     console.error("routeData, stationData or sightData is null"); | ||||
|     return <div>Loading...</div>; | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ width: "100%", height: "100%" }} ref={parentRef}> | ||||
|       <Application resizeTo={parentRef} background="#fff"> | ||||
|       <LanguageSwitcher /> | ||||
|       <Application resizeTo={parentRef} background="#fff" preference="webgl"> | ||||
|         <InfiniteCanvas> | ||||
|           <TravelPath points={points} /> | ||||
|           {stationData[language].map((obj, index) => ( | ||||
| @@ -146,20 +175,14 @@ export const RouteMap = observer(() => { | ||||
|               key={obj.id} | ||||
|               ruLabel={ | ||||
|                 language === "ru" | ||||
|                   ? stationData.en[index].name | ||||
|                   ? stationData.ru[index].name | ||||
|                   : stationData.ru[index].name | ||||
|               } | ||||
|             /> | ||||
|           ))} | ||||
|  | ||||
|           <pixiGraphics | ||||
|             draw={(g) => { | ||||
|               g.clear(); | ||||
|               const localCenter = screenToLocal(0, 0); | ||||
|               g.circle(localCenter.x, localCenter.y, 10); | ||||
|               g.fill("#fff"); | ||||
|             }} | ||||
|           /> | ||||
|           {originalSightData?.map((sight: SightData, index: number) => { | ||||
|             return <Sight sight={sight} id={index} key={sight.id} />; | ||||
|           })} | ||||
|         </InfiniteCanvas> | ||||
|       </Application> | ||||
|     </div> | ||||
|   | ||||
| @@ -1,69 +1,72 @@ | ||||
| export interface RouteData { | ||||
| 	carrier: string; | ||||
| 	carrier_id: number; | ||||
| 	center_latitude: number; | ||||
| 	center_longitude: number; | ||||
| 	governor_appeal: number; | ||||
| 	id: number; | ||||
| 	path: [number, number][]; | ||||
| 	rotate: number; | ||||
| 	route_direction: boolean; | ||||
| 	route_number: string; | ||||
| 	route_sys_number: string; | ||||
| 	scale_max: number; | ||||
| 	scale_min: number; | ||||
|   carrier: string; | ||||
|   carrier_id: number; | ||||
|   center_latitude: number; | ||||
|   center_longitude: number; | ||||
|   governor_appeal: number; | ||||
|   id: number; | ||||
|   path: [number, number][]; | ||||
|   rotate: number; | ||||
|   route_direction: boolean; | ||||
|   route_number: string; | ||||
|   route_sys_number: string; | ||||
|   scale_max: number; | ||||
|   scale_min: number; | ||||
|   thumbnail?: string; // uuid логотипа маршрута | ||||
| } | ||||
|  | ||||
| export interface StationTransferData { | ||||
| 	bus: string; | ||||
| 	metro_blue: string; | ||||
| 	metro_green: string; | ||||
| 	metro_orange: string; | ||||
| 	metro_purple: string; | ||||
| 	metro_red: string; | ||||
| 	train: string; | ||||
| 	tram: string; | ||||
| 	trolleybus: string; | ||||
|   bus: string; | ||||
|   metro_blue: string; | ||||
|   metro_green: string; | ||||
|   metro_orange: string; | ||||
|   metro_purple: string; | ||||
|   metro_red: string; | ||||
|   train: string; | ||||
|   tram: string; | ||||
|   trolleybus: string; | ||||
| } | ||||
|  | ||||
| export interface StationData { | ||||
| 	address: string; | ||||
| 	city_id?: number; | ||||
| 	description: string; | ||||
| 	id: number; | ||||
| 	latitude: number; | ||||
| 	longitude: number; | ||||
| 	name: string; | ||||
| 	offset_x: number; | ||||
| 	offset_y: number; | ||||
| 	system_name: string; | ||||
| 	transfers: StationTransferData; | ||||
|   address: string; | ||||
|   city_id?: number; | ||||
|   description: string; | ||||
|   id: number; | ||||
|   latitude: number; | ||||
|   longitude: number; | ||||
|   name: string; | ||||
|   offset_x: number; | ||||
|   offset_y: number; | ||||
|   system_name: string; | ||||
|   transfers: StationTransferData; | ||||
|   align: number; | ||||
| } | ||||
|  | ||||
| export interface StationPatchData { | ||||
| 	station_id: number; | ||||
| 	offset_x: number; | ||||
| 	offset_y: number; | ||||
| 	transfers: StationTransferData; | ||||
|   station_id: number; | ||||
|   offset_x: number; | ||||
|   offset_y: number; | ||||
|   align: number; | ||||
|   transfers: StationTransferData; | ||||
| } | ||||
|  | ||||
| export interface SightPatchData { | ||||
| 	sight_id: number; | ||||
| 	latitude: number; | ||||
| 	longitude: number; | ||||
|   sight_id: number; | ||||
|   latitude: number; | ||||
|   longitude: number; | ||||
| } | ||||
|  | ||||
| export interface SightData { | ||||
| 	address: string; | ||||
| 	city: string; | ||||
| 	city_id: number; | ||||
| 	id: number; | ||||
| 	latitude: number; | ||||
| 	left_article: number; | ||||
| 	longitude: number; | ||||
| 	name: string; | ||||
| 	preview_media: number; | ||||
| 	thumbnail: string; // uuid | ||||
| 	watermark_lu: string; // uuid | ||||
| 	watermark_rd: string; // uuid | ||||
| } | ||||
|   address: string; | ||||
|   city: string; | ||||
|   city_id: number; | ||||
|   id: number; | ||||
|   latitude: number; | ||||
|   left_article: number; | ||||
|   longitude: number; | ||||
|   name: string; | ||||
|   preview_media: number; | ||||
|   thumbnail: string; // uuid | ||||
|   watermark_lu: string; // uuid | ||||
|   watermark_rd: string; // uuid | ||||
| } | ||||
|   | ||||
| @@ -1,20 +1,38 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, sightsStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { | ||||
|   cityStore, | ||||
|   languageStore, | ||||
|   sightsStore, | ||||
|   selectedCityStore, | ||||
| } from "@shared"; | ||||
| import { useEffect, useState, useMemo } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const SightListPage = observer(() => { | ||||
|   const { sights, getSights, deleteListSight } = sightsStore; | ||||
|   const { cities, getCities } = cityStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<string | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getSights(); | ||||
|     const fetchSights = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getCities(language); | ||||
|       await getSights(); | ||||
|  | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchSights(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -22,18 +40,40 @@ export const SightListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Имя", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "city", | ||||
|       field: "city_id", | ||||
|       headerName: "Город", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               cities[language].data.find((el) => el.id == params.value)?.name | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "Действия", | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -58,10 +98,19 @@ export const SightListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = sights.map((sight) => ({ | ||||
|   // Фильтрация достопримечательностей по выбранному городу | ||||
|   const filteredSights = useMemo(() => { | ||||
|     const { selectedCityId } = selectedCityStore; | ||||
|     if (!selectedCityId) { | ||||
|       return sights; | ||||
|     } | ||||
|     return sights.filter((sight: any) => sight.city_id === selectedCityId); | ||||
|   }, [sights, selectedCityStore.selectedCityId]); | ||||
|  | ||||
|   const rows = filteredSights.map((sight) => ({ | ||||
|     id: sight.id, | ||||
|     name: sight.name, | ||||
|     city: sight.city, | ||||
|     city_id: sight.city_id, | ||||
|   })); | ||||
|  | ||||
|   return ( | ||||
| @@ -76,11 +125,41 @@ export const SightListPage = observer(() => { | ||||
|             path="/sight/create" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids as unknown as number[])); | ||||
|           }} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? ( | ||||
|                   <CircularProgress size={20} /> | ||||
|                 ) : ( | ||||
|                   "Нет достопримечательностей" | ||||
|                 )} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -98,6 +177,19 @@ export const SightListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteListSight(id))); | ||||
|           getSights(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,69 +1,68 @@ | ||||
| import { Button, Paper, TextField } from "@mui/material"; | ||||
| import { Button, TextField } from "@mui/material"; | ||||
| import { snapshotStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Loader2, Save } from "lucide-react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { useState } from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
|  | ||||
| export const SnapshotCreatePage = observer(() => { | ||||
|   const { id } = useParams(); | ||||
|   const { getSnapshot, createSnapshot } = snapshotStore; | ||||
|   const { createSnapshot } = snapshotStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [name, setName] = useState(""); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       await getSnapshot(id as string); | ||||
|     })(); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
|         > | ||||
|           <ArrowLeft size={20} /> | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <h1 className="text-2xl font-bold">Создание снапшота</h1> | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Название" | ||||
|           required | ||||
|           value={name} | ||||
|           onChange={(e) => setName(e.target.value)} | ||||
|         /> | ||||
|     <div className="w-full h-[400px] flex justify-center items-center"> | ||||
|       <div className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|         <div className="flex justify-between items-center"> | ||||
|           <button | ||||
|             className="flex items-center gap-2" | ||||
|             onClick={() => navigate(-1)} | ||||
|           > | ||||
|             <ArrowLeft size={20} /> | ||||
|             Назад | ||||
|           </button> | ||||
|         </div> | ||||
|         <h1 className="text-2xl font-bold">Создание снапшота</h1> | ||||
|         <div className="flex flex-col gap-10 w-full items-end"> | ||||
|           <TextField | ||||
|             className="w-full" | ||||
|             label="Название" | ||||
|             required | ||||
|             value={name} | ||||
|             onChange={(e) => setName(e.target.value)} | ||||
|           /> | ||||
|  | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={async () => { | ||||
|             try { | ||||
|               setIsLoading(true); | ||||
|               await createSnapshot(name); | ||||
|               setIsLoading(false); | ||||
|               toast.success("Снапшот успешно создан"); | ||||
|             } catch (error) { | ||||
|               console.error(error); | ||||
|             } | ||||
|           }} | ||||
|           disabled={isLoading} | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             color="primary" | ||||
|             className="w-min flex gap-2 items-center" | ||||
|             startIcon={<Save size={20} />} | ||||
|             onClick={async () => { | ||||
|               try { | ||||
|                 setIsLoading(true); | ||||
|                 await createSnapshot(name); | ||||
|                 setIsLoading(false); | ||||
|                 toast.success("Снапшот успешно создан"); | ||||
|                 navigate(-1); | ||||
|               } catch (error) { | ||||
|                 console.error(error); | ||||
|                 toast.error("Ошибка при создании снапшота"); | ||||
|               } finally { | ||||
|                 setIsLoading(false); | ||||
|               } | ||||
|             }} | ||||
|             disabled={isLoading || !name.trim()} | ||||
|           > | ||||
|             {isLoading ? ( | ||||
|               <Loader2 size={20} className="animate-spin" /> | ||||
|             ) : ( | ||||
|               "Сохранить" | ||||
|             )} | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Paper> | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { languageStore, snapshotStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { DatabaseBackup, Trash2 } from "lucide-react"; | ||||
|  | ||||
| import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const SnapshotListPage = observer(() => { | ||||
|   const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } = | ||||
| @@ -14,9 +15,15 @@ export const SnapshotListPage = observer(() => { | ||||
|   const [rowId, setRowId] = useState<string | null>(null); // Lifted state | ||||
|   const { language } = languageStore; | ||||
|   const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getSnapshots(); | ||||
|     const fetchSnapshots = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getSnapshots(); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchSnapshots(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -36,6 +43,7 @@ export const SnapshotListPage = observer(() => { | ||||
|       headerName: "Действия", | ||||
|       width: 300, | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -81,6 +89,15 @@ export const SnapshotListPage = observer(() => { | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           hideFooter | ||||
|           loading={isLoading} | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -101,12 +118,15 @@ export const SnapshotListPage = observer(() => { | ||||
|  | ||||
|       <SnapshotRestore | ||||
|         open={isRestoreModalOpen} | ||||
|         loading={isLoading} | ||||
|         onDelete={async () => { | ||||
|           setIsLoading(true); | ||||
|           if (rowId) { | ||||
|             await restoreSnapshot(rowId); | ||||
|           } | ||||
|           setIsRestoreModalOpen(false); | ||||
|           setRowId(null); | ||||
|           setIsLoading(false); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsRestoreModalOpen(false); | ||||
|   | ||||
| @@ -1,3 +1,2 @@ | ||||
| export * from "./SnapshotListPage"; | ||||
|  | ||||
| export * from "./SnapshotCreatePage"; | ||||
|   | ||||
							
								
								
									
										328
									
								
								src/pages/Station/LinkedSights.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								src/pages/Station/LinkedSights.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,328 @@ | ||||
| import { useState, useEffect } from "react"; | ||||
| import { | ||||
|   Stack, | ||||
|   Typography, | ||||
|   Button, | ||||
|   Accordion, | ||||
|   AccordionSummary, | ||||
|   AccordionDetails, | ||||
|   useTheme, | ||||
|   TextField, | ||||
|   Autocomplete, | ||||
|   TableCell, | ||||
|   TableContainer, | ||||
|   Table, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   Paper, | ||||
|   TableBody, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
|  | ||||
| import { authInstance, languageStore, selectedCityStore } from "@shared"; | ||||
|  | ||||
| type Field<T> = { | ||||
|   label: string; | ||||
|   data: keyof T; | ||||
|   render?: (value: any) => React.ReactNode; | ||||
| }; | ||||
|  | ||||
| type LinkedSightsProps<T> = { | ||||
|   parentId: string | number; | ||||
|   fields: Field<T>[]; | ||||
|   setItemsParent?: (items: T[]) => void; | ||||
|   type: "show" | "edit"; | ||||
|   onUpdate?: () => void; | ||||
|   disableCreation?: boolean; | ||||
|   updatedLinkedItems?: T[]; | ||||
|   refresh?: number; | ||||
| }; | ||||
|  | ||||
| export const LinkedSights = < | ||||
|   T extends { id: number; name: string; [key: string]: any } | ||||
| >( | ||||
|   props: LinkedSightsProps<T> | ||||
| ) => { | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Accordion sx={{ width: "100%" }}> | ||||
|         <AccordionSummary | ||||
|           expandIcon={<ExpandMoreIcon />} | ||||
|           sx={{ | ||||
|             background: theme.palette.background.paper, | ||||
|             borderBottom: `1px solid ${theme.palette.divider}`, | ||||
|             width: "100%", | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="subtitle1" fontWeight="bold"> | ||||
|             Привязанные достопримечательности | ||||
|           </Typography> | ||||
|         </AccordionSummary> | ||||
|  | ||||
|         <AccordionDetails | ||||
|           sx={{ background: theme.palette.background.paper, width: "100%" }} | ||||
|         > | ||||
|           <Stack gap={2} width="100%"> | ||||
|             <LinkedSightsContents {...props} /> | ||||
|           </Stack> | ||||
|         </AccordionDetails> | ||||
|       </Accordion> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const LinkedSightsContentsInner = < | ||||
|   T extends { id: number; name: string; [key: string]: any } | ||||
| >({ | ||||
|   parentId, | ||||
|   setItemsParent, | ||||
|   fields, | ||||
|   type, | ||||
|   onUpdate, | ||||
|   disableCreation = false, | ||||
|   updatedLinkedItems, | ||||
|   refresh, | ||||
| }: LinkedSightsProps<T>) => { | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   const [allItems, setAllItems] = useState<T[]>([]); | ||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]); | ||||
|   const [selectedItemId, setSelectedItemId] = useState<number | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState<boolean>(true); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   useEffect(() => {}, [error]); | ||||
|  | ||||
|   const parentResource = "station"; | ||||
|   const childResource = "sight"; | ||||
|  | ||||
|   const availableItems = allItems | ||||
|     .filter((item) => !linkedItems.some((linked) => linked.id === item.id)) | ||||
|     .filter((item) => { | ||||
|       // Фильтруем по городу из навбара | ||||
|       const selectedCityId = selectedCityStore.selectedCityId; | ||||
|       if (selectedCityId && "city_id" in item) { | ||||
|         return item.city_id === selectedCityId; | ||||
|       } | ||||
|       return true; | ||||
|     }) | ||||
|     .sort((a, b) => a.name.localeCompare(b.name)); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (updatedLinkedItems) { | ||||
|       setLinkedItems(updatedLinkedItems); | ||||
|     } | ||||
|   }, [updatedLinkedItems]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setItemsParent?.(linkedItems); | ||||
|   }, [linkedItems, setItemsParent]); | ||||
|  | ||||
|   const linkItem = () => { | ||||
|     if (selectedItemId !== null) { | ||||
|       setError(null); | ||||
|       const requestData = { | ||||
|         sight_id: selectedItemId, | ||||
|       }; | ||||
|  | ||||
|       authInstance | ||||
|         .post(`/${parentResource}/${parentId}/${childResource}`, requestData) | ||||
|         .then(() => { | ||||
|           const newItem = allItems.find((item) => item.id === selectedItemId); | ||||
|           if (newItem) { | ||||
|             setLinkedItems([...linkedItems, newItem]); | ||||
|           } | ||||
|           setSelectedItemId(null); | ||||
|           onUpdate?.(); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error("Error linking sight:", error); | ||||
|           setError("Failed to link sight"); | ||||
|         }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deleteItem = (itemId: number) => { | ||||
|     setError(null); | ||||
|     authInstance | ||||
|       .delete(`/${parentResource}/${parentId}/${childResource}`, { | ||||
|         data: { [`${childResource}_id`]: itemId }, | ||||
|       }) | ||||
|       .then(() => { | ||||
|         setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); | ||||
|         onUpdate?.(); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error("Error deleting sight:", error); | ||||
|         setError("Failed to delete sight"); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (parentId) { | ||||
|       setIsLoading(true); | ||||
|       setError(null); | ||||
|       authInstance | ||||
|         .get(`/${parentResource}/${parentId}/${childResource}`) | ||||
|         .then((response) => { | ||||
|           setLinkedItems(response?.data || []); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error("Error fetching linked sights:", error); | ||||
|           setError("Failed to load linked sights"); | ||||
|           setLinkedItems([]); | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           setIsLoading(false); | ||||
|         }); | ||||
|     } | ||||
|   }, [parentId, language, refresh]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (type === "edit") { | ||||
|       setError(null); | ||||
|       authInstance | ||||
|         .get(`/${childResource}`) | ||||
|         .then((response) => { | ||||
|           setAllItems(response?.data || []); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error("Error fetching all sights:", error); | ||||
|           setError("Failed to load available sights"); | ||||
|           setAllItems([]); | ||||
|         }); | ||||
|     } | ||||
|   }, [type]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {linkedItems?.length > 0 && ( | ||||
|         <TableContainer component={Paper} sx={{ width: "100%" }}> | ||||
|           <Table sx={{ width: "100%" }}> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell key="id" width="60px"> | ||||
|                   № | ||||
|                 </TableCell> | ||||
|                 {fields.map((field) => ( | ||||
|                   <TableCell key={String(field.data)}>{field.label}</TableCell> | ||||
|                 ))} | ||||
|                 {type === "edit" && ( | ||||
|                   <TableCell width="120px">Действие</TableCell> | ||||
|                 )} | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|  | ||||
|             <TableBody> | ||||
|               {linkedItems.map((item, index) => ( | ||||
|                 <TableRow key={item.id} hover> | ||||
|                   <TableCell>{index + 1}</TableCell> | ||||
|                   {fields.map((field, idx) => ( | ||||
|                     <TableCell key={String(field.data) + String(idx)}> | ||||
|                       {field.render | ||||
|                         ? field.render(item[field.data]) | ||||
|                         : item[field.data]} | ||||
|                     </TableCell> | ||||
|                   ))} | ||||
|                   {type === "edit" && ( | ||||
|                     <TableCell> | ||||
|                       <Button | ||||
|                         variant="outlined" | ||||
|                         color="error" | ||||
|                         size="small" | ||||
|                         onClick={(e) => { | ||||
|                           e.stopPropagation(); | ||||
|                           deleteItem(item.id); | ||||
|                         }} | ||||
|                       > | ||||
|                         Отвязать | ||||
|                       </Button> | ||||
|                     </TableCell> | ||||
|                   )} | ||||
|                 </TableRow> | ||||
|               ))} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|       )} | ||||
|  | ||||
|       {linkedItems.length === 0 && !isLoading && ( | ||||
|         <Typography color="textSecondary" textAlign="center" py={2}> | ||||
|           Достопримечательности не найдены | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       {type === "edit" && !disableCreation && ( | ||||
|         <Stack gap={2} mt={2}> | ||||
|           <Typography variant="subtitle1"> | ||||
|             Добавить достопримечательность | ||||
|           </Typography> | ||||
|           <Autocomplete | ||||
|             fullWidth | ||||
|             value={ | ||||
|               availableItems?.find((item) => item.id === selectedItemId) || null | ||||
|             } | ||||
|             onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} | ||||
|             options={availableItems} | ||||
|             getOptionLabel={(item) => String(item.name)} | ||||
|             renderInput={(params) => ( | ||||
|               <TextField | ||||
|                 {...params} | ||||
|                 label="Выберите достопримечательность" | ||||
|                 fullWidth | ||||
|               /> | ||||
|             )} | ||||
|             isOptionEqualToValue={(option, value) => option.id === value?.id} | ||||
|             filterOptions={(options, { inputValue }) => { | ||||
|               const searchWords = inputValue | ||||
|                 .toLowerCase() | ||||
|                 .split(" ") | ||||
|                 .filter(Boolean); | ||||
|               return options.filter((option) => { | ||||
|                 const optionWords = String(option.name) | ||||
|                   .toLowerCase() | ||||
|                   .split(" "); | ||||
|                 return searchWords.every((searchWord) => | ||||
|                   optionWords.some((word) => word.startsWith(searchWord)) | ||||
|                 ); | ||||
|               }); | ||||
|             }} | ||||
|             renderOption={(props, option) => ( | ||||
|               <li {...props} key={option.id}> | ||||
|                 {String(option.name)} | ||||
|               </li> | ||||
|             )} | ||||
|           /> | ||||
|  | ||||
|           <Button | ||||
|             variant="contained" | ||||
|             onClick={linkItem} | ||||
|             disabled={!selectedItemId} | ||||
|             sx={{ alignSelf: "flex-start" }} | ||||
|           > | ||||
|             Добавить | ||||
|           </Button> | ||||
|         </Stack> | ||||
|       )} | ||||
|  | ||||
|       {isLoading && ( | ||||
|         <Typography color="textSecondary" textAlign="center" py={2}> | ||||
|           Загрузка... | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       {error && ( | ||||
|         <Typography color="error" textAlign="center" py={2}> | ||||
|           {error} | ||||
|         </Typography> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const LinkedSightsContents = observer( | ||||
|   LinkedSightsContentsInner | ||||
| ) as typeof LinkedSightsContentsInner; | ||||
| @@ -8,35 +8,111 @@ import { | ||||
|   InputLabel, | ||||
| } from "@mui/material"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Loader2, Save } from "lucide-react"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { stationsStore } from "@shared"; | ||||
| import { useState } from "react"; | ||||
| import { | ||||
|   stationsStore, | ||||
|   languageStore, | ||||
|   cityStore, | ||||
|   useSelectedCity, | ||||
| } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { SaveWithoutCityAgree } from "@widgets"; | ||||
|  | ||||
| export const StationCreatePage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [name, setName] = useState(""); | ||||
|   const [systemName, setSystemName] = useState(""); | ||||
|   const [direction, setDirection] = useState(""); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|   const { | ||||
|     createStationData, | ||||
|     setCreateCommonData, | ||||
|     createStation, | ||||
|     setLanguageCreateStationData, | ||||
|   } = stationsStore; | ||||
|   const { cities, getCities } = cityStore; | ||||
|   const { selectedCityId, selectedCity } = useSelectedCity(); | ||||
|   const [coordinates, setCoordinates] = useState<string>(""); | ||||
|   // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА | ||||
|   const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); | ||||
|  | ||||
|   const handleCreate = async () => { | ||||
|   useEffect(() => { | ||||
|     if ( | ||||
|       createStationData.common.latitude !== 0 || | ||||
|       createStationData.common.longitude !== 0 | ||||
|     ) { | ||||
|       setCoordinates( | ||||
|         `${createStationData.common.latitude}, ${createStationData.common.longitude}` | ||||
|       ); | ||||
|     } | ||||
|   }, [createStationData.common.latitude, createStationData.common.longitude]); | ||||
|  | ||||
|   // НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения) | ||||
|   const executeCreate = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
|       await stationsStore.createStation(name, systemName, direction); | ||||
|       toast.success("Станция успешно создана"); | ||||
|       await createStation(); | ||||
|       toast.success("Остановка успешно создана"); | ||||
|       navigate("/station"); | ||||
|     } catch (error) { | ||||
|       console.error("Error creating station:", error); | ||||
|       toast.error("Ошибка при создании станции"); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания | ||||
|   const handleCreate = async () => { | ||||
|     const isCityMissing = !createStationData.common.city_id; | ||||
|     // Проверяем названия на всех языках | ||||
|     const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name; | ||||
|  | ||||
|     if (isCityMissing || isNameMissing) { | ||||
|       setIsSaveWarningOpen(true); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await executeCreate(); | ||||
|   }; | ||||
|  | ||||
|   // Обработчик "Да" в предупреждающем окне | ||||
|   const handleConfirmCreate = async () => { | ||||
|     setIsSaveWarningOpen(false); | ||||
|     await executeCreate(); | ||||
|   }; | ||||
|  | ||||
|   // Обработчик "Нет" в предупреждающем окне | ||||
|   const handleCancelCreate = () => { | ||||
|     setIsSaveWarningOpen(false); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchCities = async () => { | ||||
|       await getCities("ru"); | ||||
|       await getCities("en"); | ||||
|       await getCities("zh"); | ||||
|     }; | ||||
|  | ||||
|     fetchCities(); | ||||
|   }, []); | ||||
|  | ||||
|   // Автоматически устанавливаем выбранный город при загрузке страницы | ||||
|   useEffect(() => { | ||||
|     if (selectedCityId && selectedCity && !createStationData.common.city_id) { | ||||
|       setCreateCommonData({ | ||||
|         city_id: selectedCityId, | ||||
|         city: selectedCity.name, | ||||
|       }); | ||||
|     } | ||||
|   }, [selectedCityId, selectedCity, createStationData.common.city_id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <div className="flex justify-between items-center"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
|           onClick={() => navigate(-1)} | ||||
| @@ -45,42 +121,123 @@ export const StationCreatePage = observer(() => { | ||||
|           Назад | ||||
|         </button> | ||||
|       </div> | ||||
|       <h1 className="text-2xl font-bold">Создание станции</h1> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|           <h1 className="text-3xl break-words">Создание остановки</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           fullWidth | ||||
|           label="Название" | ||||
|           value={createStationData[language].name || ""} | ||||
|           required | ||||
|           value={name} | ||||
|           onChange={(e) => setName(e.target.value)} | ||||
|         /> | ||||
|         <TextField | ||||
|           className="w-full" | ||||
|           label="Системное название" | ||||
|           required | ||||
|           value={systemName} | ||||
|           onChange={(e) => setSystemName(e.target.value)} | ||||
|           onChange={(e) => | ||||
|             setLanguageCreateStationData(language, { | ||||
|               name: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <FormControl fullWidth> | ||||
|           <InputLabel>Направление</InputLabel> | ||||
|           <InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel> | ||||
|           <Select | ||||
|             value={direction} | ||||
|             label="Направление" | ||||
|             onChange={(e) => setDirection(e.target.value)} | ||||
|             required | ||||
|             labelId="direction-label" | ||||
|             value={createStationData.common.direction ? "Прямой" : "Обратный"} | ||||
|             label="Прямой/обратный маршрут" | ||||
|             onChange={(e) => | ||||
|               setCreateCommonData({ | ||||
|                 direction: e.target.value === "Прямой", | ||||
|               }) | ||||
|             } | ||||
|           > | ||||
|             <MenuItem value="forward">Прямое</MenuItem> | ||||
|             <MenuItem value="backward">Обратное</MenuItem> | ||||
|             <MenuItem value="Прямой">Прямой</MenuItem> | ||||
|             <MenuItem value="Обратный">Обратный</MenuItem> | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Описание" | ||||
|           value={createStationData.common.description || ""} | ||||
|           onChange={(e) => | ||||
|             setCreateCommonData({ | ||||
|               description: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         {/* <TextField | ||||
|           fullWidth | ||||
|           label="Адрес" | ||||
|           value={createStationData[language].address || ""} | ||||
|           onChange={(e) => | ||||
|             setLanguageCreateStationData(language, { | ||||
|               address: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> */} | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Координаты" | ||||
|           value={coordinates} | ||||
|           onChange={(e) => { | ||||
|             const newValue = e.target.value; | ||||
|             setCoordinates(newValue); | ||||
|  | ||||
|             const input = newValue.replace(/,/g, " ").trim(); | ||||
|             const [latStr, lonStr] = input.split(/\s+/); | ||||
|  | ||||
|             const lat = parseFloat(latStr); | ||||
|             const lon = parseFloat(lonStr); | ||||
|  | ||||
|             const isValidLat = !isNaN(lat); | ||||
|             const isValidLon = !isNaN(lon); | ||||
|  | ||||
|             if (isValidLat && isValidLon) { | ||||
|               setCreateCommonData({ | ||||
|                 latitude: lat, | ||||
|                 longitude: lon, | ||||
|               }); | ||||
|             } else { | ||||
|               setCreateCommonData({ | ||||
|                 latitude: 0, | ||||
|                 longitude: 0, | ||||
|               }); | ||||
|             } | ||||
|           }} | ||||
|           placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)" | ||||
|         /> | ||||
|  | ||||
|         <FormControl fullWidth> | ||||
|           <InputLabel>Город</InputLabel> | ||||
|           <Select | ||||
|             value={createStationData.common.city_id || ""} | ||||
|             label="Город" | ||||
|             onChange={(e) => { | ||||
|               const selectedCity = cities["ru"].data.find( | ||||
|                 (city) => city.id === e.target.value | ||||
|               ); | ||||
|               setCreateCommonData({ | ||||
|                 city_id: e.target.value as number, | ||||
|                 city: selectedCity?.name || "", | ||||
|               }); | ||||
|             }} | ||||
|           > | ||||
|             {cities["ru"].data.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
|             ))} | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="primary" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleCreate} | ||||
|           disabled={isLoading || !name || !systemName || !direction} | ||||
|           disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
| @@ -89,6 +246,16 @@ export const StationCreatePage = observer(() => { | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} | ||||
|       {isSaveWarningOpen && ( | ||||
|         <SaveWithoutCityAgree | ||||
|           blocker={{ | ||||
|             proceed: handleConfirmCreate, | ||||
|             reset: handleCancelCreate, | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -15,6 +15,8 @@ import { toast } from "react-toastify"; | ||||
| import { stationsStore, languageStore, cityStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { LinkedSights } from "../LinkedSights"; | ||||
| import { SaveWithoutCityAgree } from "@widgets"; | ||||
|  | ||||
| export const StationEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
| @@ -29,12 +31,31 @@ export const StationEditPage = observer(() => { | ||||
|     setLanguageEditStationData, | ||||
|   } = stationsStore; | ||||
|   const { cities, getCities } = cityStore; | ||||
|   const [coordinates, setCoordinates] = useState<string>(""); | ||||
|   // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА | ||||
|   const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); | ||||
|  | ||||
|   const handleEdit = async () => { | ||||
|   useEffect(() => { | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if ( | ||||
|       editStationData.common.latitude !== 0 || | ||||
|       editStationData.common.longitude !== 0 | ||||
|     ) { | ||||
|       setCoordinates( | ||||
|         `${editStationData.common.latitude}, ${editStationData.common.longitude}` | ||||
|       ); | ||||
|     } | ||||
|   }, [editStationData.common.latitude, editStationData.common.longitude]); | ||||
|  | ||||
|   // НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения) | ||||
|   const executeEdit = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
|       await editStation(Number(id)); | ||||
|       toast.success("Станция успешно обновлена"); | ||||
|       toast.success("Остановка успешно обновлена"); | ||||
|     } catch (error) { | ||||
|       console.error("Error updating station:", error); | ||||
|       toast.error("Ошибка при обновлении станции"); | ||||
| @@ -43,21 +64,52 @@ export const StationEditPage = observer(() => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования | ||||
|   const handleEdit = async () => { | ||||
|     const isCityMissing = !editStationData.common.city_id; | ||||
|     // Проверяем названия на всех языках | ||||
|     const isNameMissing = | ||||
|       !editStationData.ru.name || | ||||
|       !editStationData.en.name || | ||||
|       !editStationData.zh.name; | ||||
|  | ||||
|     if (isCityMissing || isNameMissing) { | ||||
|       setIsSaveWarningOpen(true); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await executeEdit(); | ||||
|   }; | ||||
|  | ||||
|   // Обработчик "Да" в предупреждающем окне | ||||
|   const handleConfirmEdit = async () => { | ||||
|     setIsSaveWarningOpen(false); | ||||
|     await executeEdit(); | ||||
|   }; | ||||
|  | ||||
|   // Обработчик "Нет" в предупреждающем окне | ||||
|   const handleCancelEdit = () => { | ||||
|     setIsSaveWarningOpen(false); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchAndSetStationData = async () => { | ||||
|       if (!id) return; | ||||
|  | ||||
|       const stationId = Number(id); | ||||
|       await getEditStation(stationId); | ||||
|       await getCities(language); | ||||
|       await getCities("ru"); | ||||
|       await getCities("en"); | ||||
|       await getCities("zh"); | ||||
|     }; | ||||
|  | ||||
|     fetchAndSetStationData(); | ||||
|   }, [id, language]); | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div className="flex items-center gap-4"> | ||||
|         <button | ||||
|           className="flex items-center gap-2" | ||||
| @@ -69,6 +121,9 @@ export const StationEditPage = observer(() => { | ||||
|       </div> | ||||
|  | ||||
|       <div className="flex flex-col gap-10 w-full items-end"> | ||||
|         <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> | ||||
|           <h1 className="text-3xl break-words">{editStationData.ru.name}</h1> | ||||
|         </div> | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Название" | ||||
| @@ -101,15 +156,15 @@ export const StationEditPage = observer(() => { | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Описание" | ||||
|           value={editStationData[language].description || ""} | ||||
|           value={editStationData.common.description || ""} | ||||
|           onChange={(e) => | ||||
|             setLanguageEditStationData(language, { | ||||
|             setEditCommonData({ | ||||
|               description: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|         {/* <TextField | ||||
|           fullWidth | ||||
|           label="Адрес" | ||||
|           value={editStationData[language].address || ""} | ||||
| @@ -118,21 +173,38 @@ export const StationEditPage = observer(() => { | ||||
|               address: e.target.value, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|         /> */} | ||||
|  | ||||
|         <TextField | ||||
|           fullWidth | ||||
|           label="Координаты" | ||||
|           value={`${editStationData.common.latitude} ${editStationData.common.longitude}`} | ||||
|           value={coordinates} | ||||
|           onChange={(e) => { | ||||
|             const [latitude, longitude] = e.target.value.split(" ").map(Number); | ||||
|             if (!isNaN(latitude) && !isNaN(longitude)) { | ||||
|             const newValue = e.target.value; | ||||
|             setCoordinates(newValue); | ||||
|  | ||||
|             const input = newValue.replace(/,/g, " ").trim(); | ||||
|             const [latStr, lonStr] = input.split(/\s+/); | ||||
|  | ||||
|             const lat = parseFloat(latStr); | ||||
|             const lon = parseFloat(lonStr); | ||||
|  | ||||
|             const isValidLat = !isNaN(lat); | ||||
|             const isValidLon = !isNaN(lon); | ||||
|  | ||||
|             if (isValidLat && isValidLon) { | ||||
|               setEditCommonData({ | ||||
|                 latitude: latitude, | ||||
|                 longitude: longitude, | ||||
|                 latitude: lat, | ||||
|                 longitude: lon, | ||||
|               }); | ||||
|             } else { | ||||
|               setEditCommonData({ | ||||
|                 latitude: 0, | ||||
|                 longitude: 0, | ||||
|               }); | ||||
|             } | ||||
|           }} | ||||
|           placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)" | ||||
|         /> | ||||
|  | ||||
|         <FormControl fullWidth> | ||||
| @@ -141,7 +213,7 @@ export const StationEditPage = observer(() => { | ||||
|             value={editStationData.common.city_id || ""} | ||||
|             label="Город" | ||||
|             onChange={(e) => { | ||||
|               const selectedCity = cities[language].find( | ||||
|               const selectedCity = cities["ru"].data.find( | ||||
|                 (city) => city.id === e.target.value | ||||
|               ); | ||||
|               setEditCommonData({ | ||||
| @@ -150,7 +222,7 @@ export const StationEditPage = observer(() => { | ||||
|               }); | ||||
|             }} | ||||
|           > | ||||
|             {cities[language].map((city) => ( | ||||
|             {cities["ru"].data.map((city) => ( | ||||
|               <MenuItem key={city.id} value={city.id}> | ||||
|                 {city.name} | ||||
|               </MenuItem> | ||||
| @@ -158,20 +230,38 @@ export const StationEditPage = observer(() => { | ||||
|           </Select> | ||||
|         </FormControl> | ||||
|  | ||||
|         {id && ( | ||||
|           <LinkedSights | ||||
|             parentId={Number(id)} | ||||
|             fields={[{ label: "Название", data: "name" }]} | ||||
|             type="edit" | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           className="w-min flex gap-2 items-center" | ||||
|           startIcon={<Save size={20} />} | ||||
|           onClick={handleEdit} | ||||
|           disabled={isLoading || !editStationData[language]?.name} | ||||
|           disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit | ||||
|         > | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Обновить" | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|  | ||||
|       {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} | ||||
|       {isSaveWarningOpen && ( | ||||
|         <SaveWithoutCityAgree | ||||
|           blocker={{ | ||||
|             proceed: handleConfirmEdit, | ||||
|             reset: handleCancelEdit, | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -1,20 +1,36 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { languageStore, stationsStore } from "@shared"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { | ||||
|   languageStore, | ||||
|   stationsStore, | ||||
|   selectedCityStore, | ||||
|   cityStore, | ||||
| } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Eye, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const StationListPage = observer(() => { | ||||
|   const { stationLists, getStationList, deleteStation } = stationsStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getStationList(); | ||||
|     const fetchStations = async () => { | ||||
|       setIsLoading(true); | ||||
|       await cityStore.getCities(language); | ||||
|       await getStationList(); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchStations(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -22,11 +38,33 @@ export const StationListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "system_name", | ||||
|       headerName: "Системное название", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "direction", | ||||
| @@ -53,6 +91,7 @@ export const StationListPage = observer(() => { | ||||
|       width: 140, | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -77,7 +116,18 @@ export const StationListPage = observer(() => { | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const rows = stationLists[language].data.map((station: any) => ({ | ||||
|   // Фильтрация станций по выбранному городу | ||||
|   const filteredStations = () => { | ||||
|     const { selectedCityId } = selectedCityStore; | ||||
|     if (!selectedCityId) { | ||||
|       return stationLists[language].data; | ||||
|     } | ||||
|     return stationLists[language].data.filter( | ||||
|       (station: any) => station.city_id === selectedCityId | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const rows = filteredStations().map((station: any) => ({ | ||||
|     id: station.id, | ||||
|     name: station.name, | ||||
|     system_name: station.system_name, | ||||
| @@ -88,16 +138,43 @@ export const StationListPage = observer(() => { | ||||
|     <> | ||||
|       <LanguageSwitcher /> | ||||
|  | ||||
|       <div style={{ width: "100%" }}> | ||||
|       <div className="w-full"> | ||||
|         <div className="flex justify-between items-center mb-10"> | ||||
|           <h1 className="text-2xl">Станции</h1> | ||||
|           <CreateButton label="Создать станцию" path="/station/create" /> | ||||
|           <CreateButton label="Создать остановки" path="/station/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? <CircularProgress size={20} /> : "Нет станций"} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -115,6 +192,19 @@ export const StationListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteStation(id))); | ||||
|           getStationList(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft } from "lucide-react"; | ||||
| import { useEffect } from "react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { LinkedSights } from "../LinkedSights"; | ||||
|  | ||||
| export const StationPreviewPage = observer(() => { | ||||
|   const { id } = useParams(); | ||||
| @@ -21,7 +22,7 @@ export const StationPreviewPage = observer(() => { | ||||
|   }, [id, language]); | ||||
|  | ||||
|   return ( | ||||
|     <Paper className="w-full h-full p-3 flex flex-col gap-10"> | ||||
|     <Paper className="w-full  p-3  py-5 flex flex-col gap-10"> | ||||
|       <LanguageSwitcher /> | ||||
|       <div className="flex justify-between items-center"> | ||||
|         <button | ||||
| @@ -71,6 +72,17 @@ export const StationPreviewPage = observer(() => { | ||||
|             <p>{stationPreview[id!]?.[language]?.data.description}</p> | ||||
|           </div> | ||||
|         )} | ||||
|  | ||||
|         {id && ( | ||||
|           <LinkedSights | ||||
|             parentId={Number(id)} | ||||
|             fields={[ | ||||
|               { label: "Название", data: "name" }, | ||||
|               { label: "Описание", data: "description" }, | ||||
|             ]} | ||||
|             type="show" | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|     </Paper> | ||||
|   ); | ||||
|   | ||||
| @@ -2,3 +2,4 @@ export * from "./StationListPage"; | ||||
| export * from "./StationCreatePage"; | ||||
| export * from "./StationPreviewPage"; | ||||
| export * from "./StationEditPage"; | ||||
| export * from "./LinkedSights"; | ||||
|   | ||||
| @@ -10,15 +10,21 @@ import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { toast } from "react-toastify"; | ||||
| import { userStore } from "@shared"; | ||||
| import { userStore, languageStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
|  | ||||
| export const UserEditPage = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   const { id } = useParams(); | ||||
|   const { editUserData, editUser, getUser, setEditUserData } = userStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   const handleEdit = async () => { | ||||
|     try { | ||||
|       setIsLoading(true); | ||||
| @@ -130,7 +136,7 @@ export const UserEditPage = observer(() => { | ||||
|           {isLoading ? ( | ||||
|             <Loader2 size={20} className="animate-spin" /> | ||||
|           ) : ( | ||||
|             "Обновить" | ||||
|             "Сохранить" | ||||
|           )} | ||||
|         </Button> | ||||
|       </div> | ||||
|   | ||||
| @@ -1,20 +1,29 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { userStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Pencil, Trash2 } from "lucide-react"; | ||||
| import { Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
|  | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const UserListPage = observer(() => { | ||||
|   const { users, getUsers, deleteUser } = userStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); // Lifted state | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getUsers(); | ||||
|     const fetchUsers = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getUsers(); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchUsers(); | ||||
|   }, []); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -22,11 +31,33 @@ export const UserListPage = observer(() => { | ||||
|       field: "name", | ||||
|       headerName: "Имя", | ||||
|       width: 400, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "email", | ||||
|       headerName: "Email", | ||||
|       width: 400, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "is_admin", | ||||
| @@ -52,6 +83,7 @@ export const UserListPage = observer(() => { | ||||
|       flex: 1, | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -93,11 +125,42 @@ export const UserListPage = observer(() => { | ||||
|           <h1 className="text-2xl">Пользователи</h1> | ||||
|           <CreateButton label="Создать пользователя" path="/user/create" /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? ( | ||||
|                   <CircularProgress size={20} /> | ||||
|                 ) : ( | ||||
|                   "Нет пользователей" | ||||
|                 )} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -107,7 +170,6 @@ export const UserListPage = observer(() => { | ||||
|           if (rowId) { | ||||
|             await deleteUser(rowId); | ||||
|           } | ||||
|  | ||||
|           setIsDeleteModalOpen(false); | ||||
|           setRowId(null); | ||||
|         }} | ||||
| @@ -116,6 +178,19 @@ export const UserListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteUser(id))); | ||||
|           getUsers(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -7,7 +7,12 @@ import { | ||||
|   FormControl, | ||||
|   InputLabel, | ||||
| } from "@mui/material"; | ||||
| import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared"; | ||||
| import { | ||||
|   vehicleStore, | ||||
|   VEHICLE_TYPES, | ||||
|   carrierStore, | ||||
|   languageStore, | ||||
| } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Save } from "lucide-react"; | ||||
| import { Loader2 } from "lucide-react"; | ||||
| @@ -21,10 +26,11 @@ export const VehicleCreatePage = observer(() => { | ||||
|   const [type, setType] = useState(""); | ||||
|   const [carrierId, setCarrierId] = useState<number | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     carrierStore.getCarriers(); | ||||
|   }, []); | ||||
|     carrierStore.getCarriers(language); | ||||
|   }, [language]); | ||||
|  | ||||
|   const handleCreate = async () => { | ||||
|     try { | ||||
| @@ -32,7 +38,8 @@ export const VehicleCreatePage = observer(() => { | ||||
|       await vehicleStore.createVehicle( | ||||
|         Number(tailNumber), | ||||
|         Number(type), | ||||
|         carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!, | ||||
|         carrierStore.carriers[language].data?.find((c) => c.id === carrierId) | ||||
|           ?.full_name as string, | ||||
|         carrierId! | ||||
|       ); | ||||
|       toast.success("Транспорт успешно создан"); | ||||
| @@ -88,7 +95,7 @@ export const VehicleCreatePage = observer(() => { | ||||
|             required | ||||
|             onChange={(e) => setCarrierId(e.target.value as number)} | ||||
|           > | ||||
|             {carrierStore.carriers.data.map((carrier) => ( | ||||
|             {carrierStore.carriers[language].data?.map((carrier) => ( | ||||
|               <MenuItem key={carrier.id} value={carrier.id}> | ||||
|                 {carrier.full_name} | ||||
|               </MenuItem> | ||||
|   | ||||
| @@ -11,7 +11,12 @@ import { observer } from "mobx-react-lite"; | ||||
| import { ArrowLeft, Loader2, Save } from "lucide-react"; | ||||
| import { useNavigate, useParams } from "react-router-dom"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { carrierStore, VEHICLE_TYPES, vehicleStore } from "@shared"; | ||||
| import { | ||||
|   carrierStore, | ||||
|   languageStore, | ||||
|   VEHICLE_TYPES, | ||||
|   vehicleStore, | ||||
| } from "@shared"; | ||||
| import { toast } from "react-toastify"; | ||||
|  | ||||
| export const VehicleEditPage = observer(() => { | ||||
| @@ -25,11 +30,18 @@ export const VehicleEditPage = observer(() => { | ||||
|     editVehicle, | ||||
|   } = vehicleStore; | ||||
|   const { getCarriers } = carrierStore; | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Устанавливаем русский язык при загрузке страницы | ||||
|     languageStore.setLanguage("ru"); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       await getVehicle(Number(id)); | ||||
|       await getCarriers(); | ||||
|       await getCarriers(language); | ||||
|  | ||||
|       setEditVehicleData({ | ||||
|         tail_number: vehicle[Number(id)]?.vehicle.tail_number, | ||||
|         type: vehicle[Number(id)]?.vehicle.type, | ||||
| @@ -37,7 +49,7 @@ export const VehicleEditPage = observer(() => { | ||||
|         carrier_id: vehicle[Number(id)]?.vehicle.carrier_id, | ||||
|       }); | ||||
|     })(); | ||||
|   }, [id]); | ||||
|   }, [id, language]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const handleEdit = async () => { | ||||
|     try { | ||||
| @@ -108,7 +120,7 @@ export const VehicleEditPage = observer(() => { | ||||
|               }) | ||||
|             } | ||||
|           > | ||||
|             {carrierStore.carriers.data.map((carrier) => ( | ||||
|             {carrierStore.carriers[language].data?.map((carrier) => ( | ||||
|               <MenuItem key={carrier.id} value={carrier.id}> | ||||
|                 {carrier.full_name} | ||||
|               </MenuItem> | ||||
|   | ||||
| @@ -1,23 +1,33 @@ | ||||
| import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; | ||||
| import { ruRU } from "@mui/x-data-grid/locales"; | ||||
| import { carrierStore, languageStore, vehicleStore } from "@shared"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { Eye, Pencil, Trash2 } from "lucide-react"; | ||||
| import { Eye, Pencil, Trash2, Minus } from "lucide-react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CreateButton, DeleteModal } from "@widgets"; | ||||
| import { VEHICLE_TYPES } from "@shared"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
|  | ||||
| export const VehicleListPage = observer(() => { | ||||
|   const { vehicles, getVehicles, deleteVehicle } = vehicleStore; | ||||
|   const { carriers, getCarriers } = carrierStore; | ||||
|   const navigate = useNavigate(); | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); | ||||
|   const [rowId, setRowId] = useState<number | null>(null); | ||||
|   const [ids, setIds] = useState<number[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { language } = languageStore; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getVehicles(); | ||||
|     getCarriers(); | ||||
|     const fetchData = async () => { | ||||
|       setIsLoading(true); | ||||
|       await getVehicles(); | ||||
|       await getCarriers(language); | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [language]); | ||||
|  | ||||
|   const columns: GridColDef[] = [ | ||||
| @@ -25,17 +35,31 @@ export const VehicleListPage = observer(() => { | ||||
|       field: "tail_number", | ||||
|       headerName: "Бортовой номер", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "type", | ||||
|       headerName: "Тип", | ||||
|       flex: 1, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="flex h-full gap-7  items-center"> | ||||
|             {VEHICLE_TYPES.find((type) => type.value === params.row.type) | ||||
|               ?.label || params.row.type} | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               VEHICLE_TYPES.find((type) => type.value === params.row.type) | ||||
|                 ?.label || params.row.type | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
| @@ -44,19 +68,41 @@ export const VehicleListPage = observer(() => { | ||||
|       field: "carrier", | ||||
|       headerName: "Перевозчик", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "city", | ||||
|       headerName: "Город", | ||||
|       flex: 1, | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
|           <div className="w-full h-full flex items-center"> | ||||
|             {params.value ? ( | ||||
|               params.value | ||||
|             ) : ( | ||||
|               <Minus size={20} className="text-red-500" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       field: "actions", | ||||
|       headerName: "Действия", | ||||
|       width: 200, | ||||
|       align: "center", | ||||
|       headerAlign: "center", | ||||
|       sortable: false, | ||||
|  | ||||
|       renderCell: (params: GridRenderCellParams) => { | ||||
|         return ( | ||||
| @@ -86,7 +132,7 @@ export const VehicleListPage = observer(() => { | ||||
|     tail_number: vehicle.vehicle.tail_number, | ||||
|     type: vehicle.vehicle.type, | ||||
|     carrier: vehicle.vehicle.carrier, | ||||
|     city: carriers.data?.find( | ||||
|     city: carriers[language].data?.find( | ||||
|       (carrier) => carrier.id === vehicle.vehicle.carrier_id | ||||
|     )?.city, | ||||
|   })); | ||||
| @@ -101,11 +147,42 @@ export const VehicleListPage = observer(() => { | ||||
|             path="/vehicle/create" | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           className="flex justify-end mb-5 duration-300" | ||||
|           style={{ opacity: ids.length > 0 ? 1 : 0 }} | ||||
|         > | ||||
|           <button | ||||
|             className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" | ||||
|             onClick={() => setIsBulkDeleteModalOpen(true)} | ||||
|           > | ||||
|             <Trash2 size={20} className="text-white" /> Удалить выбранные ( | ||||
|             {ids.length}) | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <DataGrid | ||||
|           rows={rows} | ||||
|           columns={columns} | ||||
|           hideFooterPagination | ||||
|           checkboxSelection | ||||
|           loading={isLoading} | ||||
|           onRowSelectionModelChange={(newSelection) => { | ||||
|             setIds(Array.from(newSelection.ids) as number[]); | ||||
|           }} | ||||
|           hideFooter | ||||
|           localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} | ||||
|           slots={{ | ||||
|             noRowsOverlay: () => ( | ||||
|               <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> | ||||
|                 {isLoading ? ( | ||||
|                   <CircularProgress size={20} /> | ||||
|                 ) : ( | ||||
|                   "Нет транспортных средств" | ||||
|                 )} | ||||
|               </Box> | ||||
|             ), | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
| @@ -123,6 +200,19 @@ export const VehicleListPage = observer(() => { | ||||
|           setRowId(null); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <DeleteModal | ||||
|         open={isBulkDeleteModalOpen} | ||||
|         onDelete={async () => { | ||||
|           await Promise.all(ids.map((id) => deleteVehicle(id))); | ||||
|           getVehicles(); | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|           setIds([]); | ||||
|         }} | ||||
|         onCancel={() => { | ||||
|           setIsBulkDeleteModalOpen(false); | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared"; | ||||
| import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; | ||||
|  | ||||
| const authInstance = axios.create({ | ||||
|   baseURL: "https://wn.krbl.ru", | ||||
|   baseURL: import.meta.env.VITE_API_URL, | ||||
| }); | ||||
|  | ||||
| authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { | ||||
| @@ -24,7 +24,7 @@ authInstance.interceptors.response.use( | ||||
|  | ||||
| const languageInstance = (language: Language) => { | ||||
|   const instance = axios.create({ | ||||
|     baseURL: "https://wn.krbl.ru", | ||||
|     baseURL: import.meta.env.VITE_API_URL, | ||||
|   }); | ||||
|   instance.interceptors.request.use((config) => { | ||||
|     config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`; | ||||
|   | ||||
							
								
								
									
										62
									
								
								src/shared/config/CarrierSvg.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/shared/config/CarrierSvg.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| export const CarrierSvg = () => { | ||||
|   return ( | ||||
|     <svg | ||||
|       fill="#000000" | ||||
|       height="26px" | ||||
|       width="26px" | ||||
|       version="1.1" | ||||
|       id="Capa_1" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       xmlnsXlink="http://www.w3.org/1999/xlink" | ||||
|       viewBox="0 0 489.785 489.785" | ||||
|     > | ||||
|       <g id="XMLID_196_"> | ||||
|         <path | ||||
|           id="XMLID_203_" | ||||
|           d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119 | ||||
| 		c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182 | ||||
| 		l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461 | ||||
| 		c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327 | ||||
| 		c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861 | ||||
| 		c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245 | ||||
| 		c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046 | ||||
| 		c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314 | ||||
| 		c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255 | ||||
| 		l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73 | ||||
| 		l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831 | ||||
| 		c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488 | ||||
| 		C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z | ||||
| 		 M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_202_" | ||||
|           d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615 | ||||
| 		l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_201_" | ||||
|           d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384 | ||||
| 		c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923 | ||||
| 		S194.096,172.676,176.693,160.576z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_200_" | ||||
|           d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82 | ||||
| 		c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301 | ||||
| 		c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058 | ||||
| 		c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z" | ||||
|         /> | ||||
|         <path | ||||
|           id="XMLID_197_" | ||||
|           d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031 | ||||
| 		c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322 | ||||
| 		c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031 | ||||
| 		c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317 | ||||
| 		c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3 | ||||
| 		c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179 | ||||
| 		C306.322,419.007,306.901,427.719,302.201,433.91z" | ||||
|         /> | ||||
|       </g> | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| @@ -7,23 +7,25 @@ import { | ||||
|   Users, | ||||
|   Earth, | ||||
|   Landmark, | ||||
|   BusFront, | ||||
|   GitBranch, | ||||
|   Car, | ||||
|   // Car, | ||||
|   Table, | ||||
|   Split, | ||||
|   Newspaper, | ||||
|   // Newspaper, | ||||
|   PersonStanding, | ||||
|   Cpu, | ||||
|   BookImage, | ||||
|   // BookImage, | ||||
| } from "lucide-react"; | ||||
| import { CarrierSvg } from "./CarrierSvg"; | ||||
|  | ||||
| export const DRAWER_WIDTH = 300; | ||||
|  | ||||
| interface NavigationItem { | ||||
|   id: string; | ||||
|   label: string; | ||||
|   icon: LucideIcon; | ||||
|   icon?: LucideIcon | React.ReactNode; | ||||
|   path?: string; | ||||
|   for_admin?: boolean; | ||||
|   onClick?: () => void; | ||||
|   nestedItems?: NavigationItem[]; | ||||
|   isActive?: boolean; | ||||
| @@ -34,30 +36,12 @@ export const NAVIGATION_ITEMS: { | ||||
|   secondary: NavigationItem[]; | ||||
| } = { | ||||
|   primary: [ | ||||
|     { | ||||
|       id: "countries", | ||||
|       label: "Страны", | ||||
|       icon: Earth, | ||||
|       path: "/country", | ||||
|     }, | ||||
|     { | ||||
|       id: "cities", | ||||
|       label: "Города", | ||||
|       icon: Building2, | ||||
|       path: "/city", | ||||
|     }, | ||||
|     { | ||||
|       id: "carriers", | ||||
|       label: "Перевозчики", | ||||
|       icon: BusFront, | ||||
|       path: "/carrier", | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       id: "snapshots", | ||||
|       label: "Снапшоты", | ||||
|       icon: GitBranch, | ||||
|       path: "/snapshot", | ||||
|       for_admin: true, | ||||
|     }, | ||||
|     { | ||||
|       id: "map", | ||||
| @@ -70,24 +54,38 @@ export const NAVIGATION_ITEMS: { | ||||
|       label: "Устройства", | ||||
|       icon: Cpu, | ||||
|       path: "/devices", | ||||
|       for_admin: true, | ||||
|     }, | ||||
|     // { | ||||
|     //   id: "vehicles", | ||||
|     //   label: "Транспорт", | ||||
|     //   icon: Car, | ||||
|     //   path: "/vehicle", | ||||
|     // }, | ||||
|     { | ||||
|       id: "users", | ||||
|       label: "Пользователи", | ||||
|       icon: Users, | ||||
|       path: "/user", | ||||
|       for_admin: true, | ||||
|     }, | ||||
|     { | ||||
|       id: "all", | ||||
|       label: "Все сущности", | ||||
|       label: "Справочник", | ||||
|       icon: Table, | ||||
|       nestedItems: [ | ||||
|         { | ||||
|           id: "media", | ||||
|           label: "Медиа", | ||||
|           icon: BookImage, | ||||
|           path: "/media", | ||||
|         }, | ||||
|         { | ||||
|           id: "articles", | ||||
|           label: "Статьи", | ||||
|           icon: Newspaper, | ||||
|           path: "/article", | ||||
|         }, | ||||
|         // { | ||||
|         //   id: "media", | ||||
|         //   label: "Медиа", | ||||
|         //   icon: BookImage, | ||||
|         //   path: "/media", | ||||
|         // }, | ||||
|         // { | ||||
|         //   id: "articles", | ||||
|         //   label: "Статьи", | ||||
|         //   icon: Newspaper, | ||||
|         //   path: "/article", | ||||
|         // }, | ||||
|         { | ||||
|           id: "attractions", | ||||
|           label: "Достопримечательности", | ||||
| @@ -106,20 +104,30 @@ export const NAVIGATION_ITEMS: { | ||||
|           icon: Split, | ||||
|           path: "/route", | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|  | ||||
|     { | ||||
|       id: "vehicles", | ||||
|       label: "Транспорт", | ||||
|       icon: Car, | ||||
|       path: "/vehicle", | ||||
|     }, | ||||
|     { | ||||
|       id: "users", | ||||
|       label: "Пользователи", | ||||
|       icon: Users, | ||||
|       path: "/user", | ||||
|         { | ||||
|           id: "countries", | ||||
|           label: "Страны", | ||||
|           icon: Earth, | ||||
|           path: "/country", | ||||
|           for_admin: true, | ||||
|         }, | ||||
|         { | ||||
|           id: "cities", | ||||
|           label: "Города", | ||||
|           icon: Building2, | ||||
|           path: "/city", | ||||
|           for_admin: true, | ||||
|         }, | ||||
|         { | ||||
|           id: "carriers", | ||||
|           label: "Перевозчики", | ||||
|           // @ts-ignore | ||||
|           icon: CarrierSvg, | ||||
|           path: "/carrier", | ||||
|           for_admin: true, | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|   ], | ||||
|   secondary: [ | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| export const API_URL = "https://wn.krbl.ru"; | ||||
| export const API_URL = import.meta.env.VITE_API_URL; | ||||
| export const MEDIA_TYPE_LABELS = { | ||||
|   1: "Фото", | ||||
|   2: "Видео", | ||||
| @@ -8,8 +8,10 @@ export const MEDIA_TYPE_LABELS = { | ||||
|   6: "3Д-модель", | ||||
| }; | ||||
|  | ||||
| export * from "./mediaTypes"; | ||||
|  | ||||
| export const MEDIA_TYPE_VALUES = { | ||||
|   photo: 1, | ||||
|   image: 1, | ||||
|   video: 2, | ||||
|   icon: 3, | ||||
|   thumbnail: 3, | ||||
| @@ -17,4 +19,754 @@ export const MEDIA_TYPE_VALUES = { | ||||
|   watermark_rd: 4, | ||||
|   panorama: 5, | ||||
|   model: 6, | ||||
|   video_preview: 2, | ||||
| }; | ||||
|  | ||||
| export const RU_COUNTRIES = [ | ||||
|   { code: "AF", name: "Афганистан" }, | ||||
|   { code: "AX", name: "Аландские острова" }, | ||||
|   { code: "AL", name: "Албания" }, | ||||
|   { code: "DZ", name: "Алжир" }, | ||||
|   { code: "AS", name: "Американское Самоа" }, | ||||
|   { code: "AD", name: "Андорра" }, | ||||
|   { code: "AO", name: "Ангола" }, | ||||
|   { code: "AI", name: "Ангилья" }, | ||||
|   { code: "AQ", name: "Антарктида" }, | ||||
|   { code: "AG", name: "Антигуа и Барбуда" }, | ||||
|   { code: "AR", name: "Аргентина" }, | ||||
|   { code: "AM", name: "Армения" }, | ||||
|   { code: "AW", name: "Аруба" }, | ||||
|   { code: "AU", name: "Австралия" }, | ||||
|   { code: "AT", name: "Австрия" }, | ||||
|   { code: "AZ", name: "Азербайджан" }, | ||||
|   { code: "BS", name: "Багамы" }, | ||||
|   { code: "BH", name: "Бахрейн" }, | ||||
|   { code: "BD", name: "Бангладеш" }, | ||||
|   { code: "BB", name: "Барбадос" }, | ||||
|   { code: "BY", name: "Беларусь" }, | ||||
|   { code: "BE", name: "Бельгия" }, | ||||
|   { code: "BZ", name: "Белиз" }, | ||||
|   { code: "BJ", name: "Бенин" }, | ||||
|   { code: "BM", name: "Бермуды" }, | ||||
|   { code: "BT", name: "Бутан" }, | ||||
|   { code: "BO", name: "Боливия" }, | ||||
|   { code: "BA", name: "Босния и Герцеговина" }, | ||||
|   { code: "BW", name: "Ботсвана" }, | ||||
|   { code: "BV", name: "Остров Буве" }, | ||||
|   { code: "BR", name: "Бразилия" }, | ||||
|   { code: "IO", name: "Британская территория в Индийском океане" }, | ||||
|   { code: "BN", name: "Бруней-Даруссалам" }, | ||||
|   { code: "BG", name: "Болгария" }, | ||||
|   { code: "BF", name: "Буркина-Фасо" }, | ||||
|   { code: "BI", name: "Бурунди" }, | ||||
|   { code: "KH", name: "Камбоджа" }, | ||||
|   { code: "CM", name: "Камерун" }, | ||||
|   { code: "CA", name: "Канада" }, | ||||
|   { code: "CV", name: "Кабо-Верде" }, | ||||
|   { code: "KY", name: "Каймановы острова" }, | ||||
|   { code: "CF", name: "Центральноафриканская Республика" }, | ||||
|   { code: "TD", name: "Чад" }, | ||||
|   { code: "CL", name: "Чили" }, | ||||
|   { code: "CN", name: "Китай" }, | ||||
|   { code: "CX", name: "Остров Рождества" }, | ||||
|   { code: "CC", name: "Кокосовые (Килинг) острова" }, | ||||
|   { code: "CO", name: "Колумбия" }, | ||||
|   { code: "KM", name: "Коморы" }, | ||||
|   { code: "CG", name: "Конго" }, | ||||
|   { code: "CD", name: "Демократическая Республика Конго" }, | ||||
|   { code: "CK", name: "Острова Кука" }, | ||||
|   { code: "CR", name: "Коста-Рика" }, | ||||
|   { code: "CI", name: "Кот-д'Ивуар" }, | ||||
|   { code: "HR", name: "Хорватия" }, | ||||
|   { code: "CU", name: "Куба" }, | ||||
|   { code: "CY", name: "Кипр" }, | ||||
|   { code: "CZ", name: "Чехия" }, | ||||
|   { code: "DK", name: "Дания" }, | ||||
|   { code: "DJ", name: "Джибути" }, | ||||
|   { code: "DM", name: "Доминика" }, | ||||
|   { code: "DO", name: "Доминиканская Республика" }, | ||||
|   { code: "EC", name: "Эквадор" }, | ||||
|   { code: "EG", name: "Египет" }, | ||||
|   { code: "SV", name: "Сальвадор" }, | ||||
|   { code: "GQ", name: "Экваториальная Гвинея" }, | ||||
|   { code: "ER", name: "Эритрея" }, | ||||
|   { code: "EE", name: "Эстония" }, | ||||
|   { code: "ET", name: "Эфиопия" }, | ||||
|   { code: "FK", name: "Фолклендские острова (Мальвинские)" }, | ||||
|   { code: "FO", name: "Фарерские острова" }, | ||||
|   { code: "FJ", name: "Фиджи" }, | ||||
|   { code: "FI", name: "Финляндия" }, | ||||
|   { code: "FR", name: "Франция" }, | ||||
|   { code: "GF", name: "Французская Гвиана" }, | ||||
|   { code: "PF", name: "Французская Полинезия" }, | ||||
|   { code: "TF", name: "Французские Южные территории" }, | ||||
|   { code: "GA", name: "Габон" }, | ||||
|   { code: "GM", name: "Гамбия" }, | ||||
|   { code: "GE", name: "Грузия" }, | ||||
|   { code: "DE", name: "Германия" }, | ||||
|   { code: "GH", name: "Гана" }, | ||||
|   { code: "GI", name: "Гибралтар" }, | ||||
|   { code: "GR", name: "Греция" }, | ||||
|   { code: "GL", name: "Гренландия" }, | ||||
|   { code: "GD", name: "Гренада" }, | ||||
|   { code: "GP", name: "Гваделупа" }, | ||||
|   { code: "GU", name: "Гуам" }, | ||||
|   { code: "GT", name: "Гватемала" }, | ||||
|   { code: "GG", name: "Гернси" }, | ||||
|   { code: "GN", name: "Гвинея" }, | ||||
|   { code: "GW", name: "Гвинея-Бисау" }, | ||||
|   { code: "GY", name: "Гайана" }, | ||||
|   { code: "HT", name: "Гаити" }, | ||||
|   { code: "HM", name: "Остров Херд и острова Макдональд" }, | ||||
|   { code: "VA", name: "Ватикан" }, | ||||
|   { code: "HN", name: "Гондурас" }, | ||||
|   { code: "HK", name: "Гонконг" }, | ||||
|   { code: "HU", name: "Венгрия" }, | ||||
|   { code: "IS", name: "Исландия" }, | ||||
|   { code: "IN", name: "Индия" }, | ||||
|   { code: "ID", name: "Индонезия" }, | ||||
|   { code: "IR", name: "Иран" }, | ||||
|   { code: "IQ", name: "Ирак" }, | ||||
|   { code: "IE", name: "Ирландия" }, | ||||
|   { code: "IM", name: "Остров Мэн" }, | ||||
|   { code: "IL", name: "Израиль" }, | ||||
|   { code: "IT", name: "Италия" }, | ||||
|   { code: "JM", name: "Ямайка" }, | ||||
|   { code: "JP", name: "Япония" }, | ||||
|   { code: "JE", name: "Джерси" }, | ||||
|   { code: "JO", name: "Иордания" }, | ||||
|   { code: "KZ", name: "Казахстан" }, | ||||
|   { code: "KE", name: "Кения" }, | ||||
|   { code: "KI", name: "Кирибати" }, | ||||
|   { code: "KR", name: "Корея" }, | ||||
|   { code: "KP", name: "Северная Корея" }, | ||||
|   { code: "KW", name: "Кувейт" }, | ||||
|   { code: "KG", name: "Киргизия" }, | ||||
|   { code: "LA", name: "Лаос" }, | ||||
|   { code: "LV", name: "Латвия" }, | ||||
|   { code: "LB", name: "Ливан" }, | ||||
|   { code: "LS", name: "Лесото" }, | ||||
|   { code: "LR", name: "Либерия" }, | ||||
|   { code: "LY", name: "Ливия" }, | ||||
|   { code: "LI", name: "Лихтенштейн" }, | ||||
|   { code: "LT", name: "Литва" }, | ||||
|   { code: "LU", name: "Люксембург" }, | ||||
|   { code: "MO", name: "Макао" }, | ||||
|   { code: "MK", name: "Северная Македония" }, | ||||
|   { code: "MG", name: "Мадагаскар" }, | ||||
|   { code: "MW", name: "Малави" }, | ||||
|   { code: "MY", name: "Малайзия" }, | ||||
|   { code: "MV", name: "Мальдивы" }, | ||||
|   { code: "ML", name: "Мали" }, | ||||
|   { code: "MT", name: "Мальта" }, | ||||
|   { code: "MH", name: "Маршалловы Острова" }, | ||||
|   { code: "MQ", name: "Мартиника" }, | ||||
|   { code: "MR", name: "Мавритания" }, | ||||
|   { code: "MU", name: "Маврикий" }, | ||||
|   { code: "YT", name: "Майотта" }, | ||||
|   { code: "MX", name: "Мексика" }, | ||||
|   { code: "FM", name: "Микронезия" }, | ||||
|   { code: "MD", name: "Молдова" }, | ||||
|   { code: "MC", name: "Монако" }, | ||||
|   { code: "MN", name: "Монголия" }, | ||||
|   { code: "ME", name: "Черногория" }, | ||||
|   { code: "MS", name: "Монтсеррат" }, | ||||
|   { code: "MA", name: "Марокко" }, | ||||
|   { code: "MZ", name: "Мозамбик" }, | ||||
|   { code: "MM", name: "Мьянма" }, | ||||
|   { code: "NA", name: "Намибия" }, | ||||
|   { code: "NR", name: "Науру" }, | ||||
|   { code: "NP", name: "Непал" }, | ||||
|   { code: "NL", name: "Нидерланды" }, | ||||
|   { code: "AN", name: "Нидерландские Антильские острова" }, | ||||
|   { code: "NC", name: "Новая Каледония" }, | ||||
|   { code: "NZ", name: "Новая Зеландия" }, | ||||
|   { code: "NI", name: "Никарагуа" }, | ||||
|   { code: "NE", name: "Нигер" }, | ||||
|   { code: "NG", name: "Нигерия" }, | ||||
|   { code: "NU", name: "Ниуэ" }, | ||||
|   { code: "NF", name: "Остров Норфолк" }, | ||||
|   { code: "MP", name: "Северные Марианские острова" }, | ||||
|   { code: "NO", name: "Норвегия" }, | ||||
|   { code: "OM", name: "Оман" }, | ||||
|   { code: "PK", name: "Пакистан" }, | ||||
|   { code: "PW", name: "Палау" }, | ||||
|   { code: "PS", name: "Палестинская территория" }, | ||||
|   { code: "PA", name: "Панама" }, | ||||
|   { code: "PG", name: "Папуа — Новая Гвинея" }, | ||||
|   { code: "PY", name: "Парагвай" }, | ||||
|   { code: "PE", name: "Перу" }, | ||||
|   { code: "PH", name: "Филиппины" }, | ||||
|   { code: "PN", name: "Питкэрн" }, | ||||
|   { code: "PL", name: "Польша" }, | ||||
|   { code: "PT", name: "Португалия" }, | ||||
|   { code: "PR", name: "Пуэрто-Рико" }, | ||||
|   { code: "QA", name: "Катар" }, | ||||
|   { code: "RE", name: "Реюньон" }, | ||||
|   { code: "RO", name: "Румыния" }, | ||||
|   { code: "RU", name: "Россия" }, | ||||
|   { code: "RW", name: "Руанда" }, | ||||
|   { code: "BL", name: "Сен-Бартелеми" }, | ||||
|   { code: "SH", name: "Остров Святой Елены" }, | ||||
|   { code: "KN", name: "Сент-Китс и Невис" }, | ||||
|   { code: "LC", name: "Сент-Люсия" }, | ||||
|   { code: "MF", name: "Сен-Мартен" }, | ||||
|   { code: "PM", name: "Сен-Пьер и Микелон" }, | ||||
|   { code: "VC", name: "Сент-Винсент и Гренадины" }, | ||||
|   { code: "WS", name: "Самоа" }, | ||||
|   { code: "SM", name: "Сан-Марино" }, | ||||
|   { code: "ST", name: "Сан-Томе и Принсипи" }, | ||||
|   { code: "SA", name: "Саудовская Аравия" }, | ||||
|   { code: "SN", name: "Сенегал" }, | ||||
|   { code: "RS", name: "Сербия" }, | ||||
|   { code: "SC", name: "Сейшельские Острова" }, | ||||
|   { code: "SL", name: "Сьерра-Леоне" }, | ||||
|   { code: "SG", name: "Сингапур" }, | ||||
|   { code: "SK", name: "Словакия" }, | ||||
|   { code: "SI", name: "Словения" }, | ||||
|   { code: "SB", name: "Соломоновы Острова" }, | ||||
|   { code: "SO", name: "Сомали" }, | ||||
|   { code: "ZA", name: "Южная Африка" }, | ||||
|   { code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" }, | ||||
|   { code: "ES", name: "Испания" }, | ||||
|   { code: "LK", name: "Шри-Ланка" }, | ||||
|   { code: "SD", name: "Судан" }, | ||||
|   { code: "SR", name: "Суринам" }, | ||||
|   { code: "SJ", name: "Шпицберген и Ян-Майен" }, | ||||
|   { code: "SZ", name: "Свазиленд" }, | ||||
|   { code: "SE", name: "Швеция" }, | ||||
|   { code: "CH", name: "Швейцария" }, | ||||
|   { code: "SY", name: "Сирия" }, | ||||
|   { code: "TW", name: "Тайвань" }, | ||||
|   { code: "TJ", name: "Таджикистан" }, | ||||
|   { code: "TZ", name: "Танзания" }, | ||||
|   { code: "TH", name: "Таиланд" }, | ||||
|   { code: "TL", name: "Восточный Тимор" }, | ||||
|   { code: "TG", name: "Того" }, | ||||
|   { code: "TK", name: "Токелау" }, | ||||
|   { code: "TO", name: "Тонга" }, | ||||
|   { code: "TT", name: "Тринидад и Тобаго" }, | ||||
|   { code: "TN", name: "Тунис" }, | ||||
|   { code: "TR", name: "Турция" }, | ||||
|   { code: "TM", name: "Туркмения" }, | ||||
|   { code: "TC", name: "Теркс и Кайкос" }, | ||||
|   { code: "TV", name: "Тувалу" }, | ||||
|   { code: "UG", name: "Уганда" }, | ||||
|   { code: "UA", name: "Украина" }, | ||||
|   { code: "AE", name: "Объединённые Арабские Эмираты" }, | ||||
|   { code: "GB", name: "Великобритания" }, | ||||
|   { code: "US", name: "США" }, | ||||
|   { code: "UM", name: "Внешние малые острова США" }, | ||||
|   { code: "UY", name: "Уругвай" }, | ||||
|   { code: "UZ", name: "Узбекистан" }, | ||||
|   { code: "VU", name: "Вануату" }, | ||||
|   { code: "VE", name: "Венесуэла" }, | ||||
|   { code: "VN", name: "Вьетнам" }, | ||||
|   { code: "VG", name: "Британские Виргинские острова" }, | ||||
|   { code: "VI", name: "Виргинские острова (США)" }, | ||||
|   { code: "WF", name: "Уоллис и Футуна" }, | ||||
|   { code: "EH", name: "Западная Сахара" }, | ||||
|   { code: "YE", name: "Йемен" }, | ||||
|   { code: "ZM", name: "Замбия" }, | ||||
|   { code: "ZW", name: "Зимбабве" }, | ||||
| ]; | ||||
|  | ||||
| // countries-en.js | ||||
| export const EN_COUNTRIES = [ | ||||
|   { code: "AF", name: "Afghanistan" }, | ||||
|   { code: "AX", name: "Aland Islands" }, | ||||
|   { code: "AL", name: "Albania" }, | ||||
|   { code: "DZ", name: "Algeria" }, | ||||
|   { code: "AS", name: "American Samoa" }, | ||||
|   { code: "AD", name: "Andorra" }, | ||||
|   { code: "AO", name: "Angola" }, | ||||
|   { code: "AI", name: "Anguilla" }, | ||||
|   { code: "AQ", name: "Antarctica" }, | ||||
|   { code: "AG", name: "Antigua And Barbuda" }, | ||||
|   { code: "AR", name: "Argentina" }, | ||||
|   { code: "AM", name: "Armenia" }, | ||||
|   { code: "AW", name: "Aruba" }, | ||||
|   { code: "AU", name: "Australia" }, | ||||
|   { code: "AT", name: "Austria" }, | ||||
|   { code: "AZ", name: "Azerbaijan" }, | ||||
|   { code: "BS", name: "Bahamas" }, | ||||
|   { code: "BH", name: "Bahrain" }, | ||||
|   { code: "BD", name: "Bangladesh" }, | ||||
|   { code: "BB", name: "Barbados" }, | ||||
|   { code: "BY", name: "Belarus" }, | ||||
|   { code: "BE", name: "Belgium" }, | ||||
|   { code: "BZ", name: "Belize" }, | ||||
|   { code: "BJ", name: "Benin" }, | ||||
|   { code: "BM", name: "Bermuda" }, | ||||
|   { code: "BT", name: "Bhutan" }, | ||||
|   { code: "BO", name: "Bolivia" }, | ||||
|   { code: "BA", name: "Bosnia And Herzegovina" }, | ||||
|   { code: "BW", name: "Botswana" }, | ||||
|   { code: "BV", name: "Bouvet Island" }, | ||||
|   { code: "BR", name: "Brazil" }, | ||||
|   { code: "IO", name: "British Indian Ocean Territory" }, | ||||
|   { code: "BN", name: "Brunei Darussalam" }, | ||||
|   { code: "BG", name: "Bulgaria" }, | ||||
|   { code: "BF", name: "Burkina Faso" }, | ||||
|   { code: "BI", name: "Burundi" }, | ||||
|   { code: "KH", name: "Cambodia" }, | ||||
|   { code: "CM", name: "Cameroon" }, | ||||
|   { code: "CA", name: "Canada" }, | ||||
|   { code: "CV", name: "Cape Verde" }, | ||||
|   { code: "KY", name: "Cayman Islands" }, | ||||
|   { code: "CF", name: "Central African Republic" }, | ||||
|   { code: "TD", name: "Chad" }, | ||||
|   { code: "CL", name: "Chile" }, | ||||
|   { code: "CN", name: "China" }, | ||||
|   { code: "CX", name: "Christmas Island" }, | ||||
|   { code: "CC", name: "Cocos (Keeling) Islands" }, | ||||
|   { code: "CO", name: "Colombia" }, | ||||
|   { code: "KM", name: "Comoros" }, | ||||
|   { code: "CG", name: "Congo" }, | ||||
|   { code: "CD", name: "Congo, Democratic Republic" }, | ||||
|   { code: "CK", name: "Cook Islands" }, | ||||
|   { code: "CR", name: "Costa Rica" }, | ||||
|   { code: "CI", name: "Cote D'Ivoire" }, | ||||
|   { code: "HR", name: "Croatia" }, | ||||
|   { code: "CU", name: "Cuba" }, | ||||
|   { code: "CY", name: "Cyprus" }, | ||||
|   { code: "CZ", name: "Czech Republic" }, | ||||
|   { code: "DK", name: "Denmark" }, | ||||
|   { code: "DJ", name: "Djibouti" }, | ||||
|   { code: "DM", name: "Dominica" }, | ||||
|   { code: "DO", name: "Dominican Republic" }, | ||||
|   { code: "EC", name: "Ecuador" }, | ||||
|   { code: "EG", name: "Egypt" }, | ||||
|   { code: "SV", name: "El Salvador" }, | ||||
|   { code: "GQ", name: "Equatorial Guinea" }, | ||||
|   { code: "ER", name: "Eritrea" }, | ||||
|   { code: "EE", name: "Estonia" }, | ||||
|   { code: "ET", name: "Ethiopia" }, | ||||
|   { code: "FK", name: "Falkland Islands (Malvinas)" }, | ||||
|   { code: "FO", name: "Faroe Islands" }, | ||||
|   { code: "FJ", name: "Fiji" }, | ||||
|   { code: "FI", name: "Finland" }, | ||||
|   { code: "FR", name: "France" }, | ||||
|   { code: "GF", name: "French Guiana" }, | ||||
|   { code: "PF", name: "French Polynesia" }, | ||||
|   { code: "TF", name: "French Southern Territories" }, | ||||
|   { code: "GA", name: "Gabon" }, | ||||
|   { code: "GM", name: "Gambia" }, | ||||
|   { code: "GE", name: "Georgia" }, | ||||
|   { code: "DE", name: "Germany" }, | ||||
|   { code: "GH", name: "Ghana" }, | ||||
|   { code: "GI", name: "Gibraltar" }, | ||||
|   { code: "GR", name: "Greece" }, | ||||
|   { code: "GL", name: "Greenland" }, | ||||
|   { code: "GD", name: "Grenada" }, | ||||
|   { code: "GP", name: "Guadeloupe" }, | ||||
|   { code: "GU", name: "Guam" }, | ||||
|   { code: "GT", name: "Guatemala" }, | ||||
|   { code: "GG", name: "Guernsey" }, | ||||
|   { code: "GN", name: "Guinea" }, | ||||
|   { code: "GW", name: "Guinea-Bissau" }, | ||||
|   { code: "GY", name: "Guyana" }, | ||||
|   { code: "HT", name: "Haiti" }, | ||||
|   { code: "HM", name: "Heard Island & Mcdonald Islands" }, | ||||
|   { code: "VA", name: "Holy See (Vatican City State)" }, | ||||
|   { code: "HN", name: "Honduras" }, | ||||
|   { code: "HK", name: "Hong Kong" }, | ||||
|   { code: "HU", name: "Hungary" }, | ||||
|   { code: "IS", name: "Iceland" }, | ||||
|   { code: "IN", name: "India" }, | ||||
|   { code: "ID", name: "Indonesia" }, | ||||
|   { code: "IR", name: "Iran, Islamic Republic Of" }, | ||||
|   { code: "IQ", name: "Iraq" }, | ||||
|   { code: "IE", name: "Ireland" }, | ||||
|   { code: "IM", name: "Isle Of Man" }, | ||||
|   { code: "IL", name: "Israel" }, | ||||
|   { code: "IT", name: "Italy" }, | ||||
|   { code: "JM", name: "Jamaica" }, | ||||
|   { code: "JP", name: "Japan" }, | ||||
|   { code: "JE", name: "Jersey" }, | ||||
|   { code: "JO", name: "Jordan" }, | ||||
|   { code: "KZ", name: "Kazakhstan" }, | ||||
|   { code: "KE", name: "Kenya" }, | ||||
|   { code: "KI", name: "Kiribati" }, | ||||
|   { code: "KR", name: "Korea" }, | ||||
|   { code: "KP", name: "North Korea" }, | ||||
|   { code: "KW", name: "Kuwait" }, | ||||
|   { code: "KG", name: "Kyrgyzstan" }, | ||||
|   { code: "LA", name: "Lao People's Democratic Republic" }, | ||||
|   { code: "LV", name: "Latvia" }, | ||||
|   { code: "LB", name: "Lebanon" }, | ||||
|   { code: "LS", name: "Lesotho" }, | ||||
|   { code: "LR", name: "Liberia" }, | ||||
|   { code: "LY", name: "Libyan Arab Jamahiriya" }, | ||||
|   { code: "LI", name: "Liechtenstein" }, | ||||
|   { code: "LT", name: "Lithuania" }, | ||||
|   { code: "LU", name: "Luxembourg" }, | ||||
|   { code: "MO", name: "Macao" }, | ||||
|   { code: "MK", name: "Macedonia" }, | ||||
|   { code: "MG", name: "Madagascar" }, | ||||
|   { code: "MW", name: "Malawi" }, | ||||
|   { code: "MY", name: "Malaysia" }, | ||||
|   { code: "MV", name: "Maldives" }, | ||||
|   { code: "ML", name: "Mali" }, | ||||
|   { code: "MT", name: "Malta" }, | ||||
|   { code: "MH", name: "Marshall Islands" }, | ||||
|   { code: "MQ", name: "Martinique" }, | ||||
|   { code: "MR", name: "Mauritania" }, | ||||
|   { code: "MU", name: "Mauritius" }, | ||||
|   { code: "YT", name: "Mayotte" }, | ||||
|   { code: "MX", name: "Mexico" }, | ||||
|   { code: "FM", name: "Micronesia, Federated States Of" }, | ||||
|   { code: "MD", name: "Moldova" }, | ||||
|   { code: "MC", name: "Monaco" }, | ||||
|   { code: "MN", name: "Mongolia" }, | ||||
|   { code: "ME", name: "Montenegro" }, | ||||
|   { code: "MS", name: "Montserrat" }, | ||||
|   { code: "MA", name: "Morocco" }, | ||||
|   { code: "MZ", name: "Mozambique" }, | ||||
|   { code: "MM", name: "Myanmar" }, | ||||
|   { code: "NA", name: "Namibia" }, | ||||
|   { code: "NR", name: "Nauru" }, | ||||
|   { code: "NP", name: "Nepal" }, | ||||
|   { code: "NL", name: "Netherlands" }, | ||||
|   { code: "AN", name: "Netherlands Antilles" }, | ||||
|   { code: "NC", name: "New Caledonia" }, | ||||
|   { code: "NZ", name: "New Zealand" }, | ||||
|   { code: "NI", name: "Nicaragua" }, | ||||
|   { code: "NE", name: "Niger" }, | ||||
|   { code: "NG", name: "Nigeria" }, | ||||
|   { code: "NU", name: "Niue" }, | ||||
|   { code: "NF", name: "Norfolk Island" }, | ||||
|   { code: "MP", name: "Northern Mariana Islands" }, | ||||
|   { code: "NO", name: "Norway" }, | ||||
|   { code: "OM", name: "Oman" }, | ||||
|   { code: "PK", name: "Pakistan" }, | ||||
|   { code: "PW", name: "Palau" }, | ||||
|   { code: "PS", name: "Palestinian Territory, Occupied" }, | ||||
|   { code: "PA", name: "Panama" }, | ||||
|   { code: "PG", name: "Papua New Guinea" }, | ||||
|   { code: "PY", name: "Paraguay" }, | ||||
|   { code: "PE", name: "Peru" }, | ||||
|   { code: "PH", name: "Philippines" }, | ||||
|   { code: "PN", name: "Pitcairn" }, | ||||
|   { code: "PL", name: "Poland" }, | ||||
|   { code: "PT", name: "Portugal" }, | ||||
|   { code: "PR", name: "Puerto Rico" }, | ||||
|   { code: "QA", name: "Qatar" }, | ||||
|   { code: "RE", name: "Reunion" }, | ||||
|   { code: "RO", name: "Romania" }, | ||||
|   { code: "RU", name: "Russian Federation" }, | ||||
|   { code: "RW", name: "Rwanda" }, | ||||
|   { code: "BL", name: "Saint Barthelemy" }, | ||||
|   { code: "SH", name: "Saint Helena" }, | ||||
|   { code: "KN", name: "Saint Kitts And Nevis" }, | ||||
|   { code: "LC", name: "Saint Lucia" }, | ||||
|   { code: "MF", name: "Saint Martin" }, | ||||
|   { code: "PM", name: "Saint Pierre And Miquelon" }, | ||||
|   { code: "VC", name: "Saint Vincent And Grenadines" }, | ||||
|   { code: "WS", name: "Samoa" }, | ||||
|   { code: "SM", name: "San Marino" }, | ||||
|   { code: "ST", name: "Sao Tome And Principe" }, | ||||
|   { code: "SA", name: "Saudi Arabia" }, | ||||
|   { code: "SN", name: "Senegal" }, | ||||
|   { code: "RS", name: "Serbia" }, | ||||
|   { code: "SC", name: "Seychelles" }, | ||||
|   { code: "SL", name: "Sierra Leone" }, | ||||
|   { code: "SG", name: "Singapore" }, | ||||
|   { code: "SK", name: "Slovakia" }, | ||||
|   { code: "SI", name: "Slovenia" }, | ||||
|   { code: "SB", name: "Solomon Islands" }, | ||||
|   { code: "SO", name: "Somalia" }, | ||||
|   { code: "ZA", name: "South Africa" }, | ||||
|   { code: "GS", name: "South Georgia And Sandwich Isl." }, | ||||
|   { code: "ES", name: "Spain" }, | ||||
|   { code: "LK", name: "Sri Lanka" }, | ||||
|   { code: "SD", name: "Sudan" }, | ||||
|   { code: "SR", name: "Suriname" }, | ||||
|   { code: "SJ", name: "Svalbard And Jan Mayen" }, | ||||
|   { code: "SZ", name: "Swaziland" }, | ||||
|   { code: "SE", name: "Sweden" }, | ||||
|   { code: "CH", name: "Switzerland" }, | ||||
|   { code: "SY", name: "Syrian Arab Republic" }, | ||||
|   { code: "TW", name: "Taiwan" }, | ||||
|   { code: "TJ", name: "Tajikistan" }, | ||||
|   { code: "TZ", name: "Tanzania" }, | ||||
|   { code: "TH", name: "Thailand" }, | ||||
|   { code: "TL", name: "Timor-Leste" }, | ||||
|   { code: "TG", name: "Togo" }, | ||||
|   { code: "TK", name: "Tokelau" }, | ||||
|   { code: "TO", name: "Tonga" }, | ||||
|   { code: "TT", name: "Trinidad And Tobago" }, | ||||
|   { code: "TN", name: "Tunisia" }, | ||||
|   { code: "TR", name: "Turkey" }, | ||||
|   { code: "TM", name: "Turkmenistan" }, | ||||
|   { code: "TC", name: "Turks And Caicos Islands" }, | ||||
|   { code: "TV", name: "Tuvalu" }, | ||||
|   { code: "UG", name: "Uganda" }, | ||||
|   { code: "UA", name: "Ukraine" }, | ||||
|   { code: "AE", name: "United Arab Emirates" }, | ||||
|   { code: "GB", name: "United Kingdom" }, | ||||
|   { code: "US", name: "United States" }, | ||||
|   { code: "UM", name: "United States Outlying Islands" }, | ||||
|   { code: "UY", name: "Uruguay" }, | ||||
|   { code: "UZ", name: "Uzbekistan" }, | ||||
|   { code: "VU", name: "Vanuatu" }, | ||||
|   { code: "VE", name: "Venezuela" }, | ||||
|   { code: "VN", name: "Vietnam" }, | ||||
|   { code: "VG", name: "Virgin Islands, British" }, | ||||
|   { code: "VI", name: "Virgin Islands, U.S." }, | ||||
|   { code: "WF", name: "Wallis And Futuna" }, | ||||
|   { code: "EH", name: "Western Sahara" }, | ||||
|   { code: "YE", name: "Yemen" }, | ||||
|   { code: "ZM", name: "Zambia" }, | ||||
|   { code: "ZW", name: "Zimbabwe" }, | ||||
| ]; | ||||
|  | ||||
| // countries-zh.js | ||||
| export const ZH_COUNTRIES = [ | ||||
|   { code: "AF", name: "阿富汗" }, | ||||
|   { code: "AX", name: "奥兰群岛" }, | ||||
|   { code: "AL", name: "阿尔巴尼亚" }, | ||||
|   { code: "DZ", name: "阿尔及利亚" }, | ||||
|   { code: "AS", name: "美属萨摩亚" }, | ||||
|   { code: "AD", name: "安道尔" }, | ||||
|   { code: "AO", name: "安哥拉" }, | ||||
|   { code: "AI", name: "安圭拉" }, | ||||
|   { code: "AQ", name: "南极洲" }, | ||||
|   { code: "AG", name: "安提瓜和巴布达" }, | ||||
|   { code: "AR", name: "阿根廷" }, | ||||
|   { code: "AM", name: "亚美尼亚" }, | ||||
|   { code: "AW", name: "阿鲁巴" }, | ||||
|   { code: "AU", name: "澳大利亚" }, | ||||
|   { code: "AT", name: "奥地利" }, | ||||
|   { code: "AZ", name: "阿塞拜疆" }, | ||||
|   { code: "BS", name: "巴哈马" }, | ||||
|   { code: "BH", name: "巴林" }, | ||||
|   { code: "BD", name: "孟加拉国" }, | ||||
|   { code: "BB", name: "巴巴多斯" }, | ||||
|   { code: "BY", name: "白俄罗斯" }, | ||||
|   { code: "BE", name: "比利时" }, | ||||
|   { code: "BZ", name: "伯利兹" }, | ||||
|   { code: "BJ", name: "贝宁" }, | ||||
|   { code: "BM", name: "百慕大" }, | ||||
|   { code: "BT", name: "不丹" }, | ||||
|   { code: "BO", name: "玻利维亚" }, | ||||
|   { code: "BA", name: "波斯尼亚和黑塞哥维那" }, | ||||
|   { code: "BW", name: "博茨瓦纳" }, | ||||
|   { code: "BV", name: "布韦岛" }, | ||||
|   { code: "BR", name: "巴西" }, | ||||
|   { code: "IO", name: "英属印度洋领地" }, | ||||
|   { code: "BN", name: "文莱" }, | ||||
|   { code: "BG", name: "保加利亚" }, | ||||
|   { code: "BF", name: "布基纳法索" }, | ||||
|   { code: "BI", name: "布隆迪" }, | ||||
|   { code: "KH", name: "柬埔寨" }, | ||||
|   { code: "CM", name: "喀麦隆" }, | ||||
|   { code: "CA", name: "加拿大" }, | ||||
|   { code: "CV", name: "佛得角" }, | ||||
|   { code: "KY", name: "开曼群岛" }, | ||||
|   { code: "CF", name: "中非共和国" }, | ||||
|   { code: "TD", name: "乍得" }, | ||||
|   { code: "CL", name: "智利" }, | ||||
|   { code: "CN", name: "中国" }, | ||||
|   { code: "CX", name: "圣诞岛" }, | ||||
|   { code: "CC", name: "科科斯(基林)群岛" }, | ||||
|   { code: "CO", name: "哥伦比亚" }, | ||||
|   { code: "KM", name: "科摩罗" }, | ||||
|   { code: "CG", name: "刚果" }, | ||||
|   { code: "CD", name: "刚果(金)" }, | ||||
|   { code: "CK", name: "库克群岛" }, | ||||
|   { code: "CR", name: "哥斯达黎加" }, | ||||
|   { code: "CI", name: "科特迪瓦" }, | ||||
|   { code: "HR", name: "克罗地亚" }, | ||||
|   { code: "CU", name: "古巴" }, | ||||
|   { code: "CY", name: "塞浦路斯" }, | ||||
|   { code: "CZ", name: "捷克" }, | ||||
|   { code: "DK", name: "丹麦" }, | ||||
|   { code: "DJ", name: "吉布提" }, | ||||
|   { code: "DM", name: "多米尼克" }, | ||||
|   { code: "DO", name: "多米尼加共和国" }, | ||||
|   { code: "EC", name: "厄瓜多尔" }, | ||||
|   { code: "EG", name: "埃及" }, | ||||
|   { code: "SV", name: "萨尔瓦多" }, | ||||
|   { code: "GQ", name: "赤道几内亚" }, | ||||
|   { code: "ER", name: "厄立特里亚" }, | ||||
|   { code: "EE", name: "爱沙尼亚" }, | ||||
|   { code: "ET", name: "埃塞俄比亚" }, | ||||
|   { code: "FK", name: "福克兰群岛" }, | ||||
|   { code: "FO", name: "法罗群岛" }, | ||||
|   { code: "FJ", name: "斐济" }, | ||||
|   { code: "FI", name: "芬兰" }, | ||||
|   { code: "FR", name: "法国" }, | ||||
|   { code: "GF", name: "法属圭亚那" }, | ||||
|   { code: "PF", name: "法属波利尼西亚" }, | ||||
|   { code: "TF", name: "法属南部领地" }, | ||||
|   { code: "GA", name: "加蓬" }, | ||||
|   { code: "GM", name: "冈比亚" }, | ||||
|   { code: "GE", name: "格鲁吉亚" }, | ||||
|   { code: "DE", name: "德国" }, | ||||
|   { code: "GH", name: "加纳" }, | ||||
|   { code: "GI", name: "直布罗陀" }, | ||||
|   { code: "GR", name: "希腊" }, | ||||
|   { code: "GL", name: "格陵兰" }, | ||||
|   { code: "GD", name: "格林纳达" }, | ||||
|   { code: "GP", name: "瓜德罗普" }, | ||||
|   { code: "GU", name: "关岛" }, | ||||
|   { code: "GT", name: "危地马拉" }, | ||||
|   { code: "GG", name: "根西岛" }, | ||||
|   { code: "GN", name: "几内亚" }, | ||||
|   { code: "GW", name: "几内亚比绍" }, | ||||
|   { code: "GY", name: "圭亚那" }, | ||||
|   { code: "HT", name: "海地" }, | ||||
|   { code: "HM", name: "赫德岛和麦克唐纳群岛" }, | ||||
|   { code: "VA", name: "梵蒂冈" }, | ||||
|   { code: "HN", name: "洪都拉斯" }, | ||||
|   { code: "HK", name: "中国香港" }, | ||||
|   { code: "HU", name: "匈牙利" }, | ||||
|   { code: "IS", name: "冰岛" }, | ||||
|   { code: "IN", name: "印度" }, | ||||
|   { code: "ID", name: "印度尼西亚" }, | ||||
|   { code: "IR", name: "伊朗" }, | ||||
|   { code: "IQ", name: "伊拉克" }, | ||||
|   { code: "IE", name: "爱尔兰" }, | ||||
|   { code: "IM", name: "马恩岛" }, | ||||
|   { code: "IL", name: "以色列" }, | ||||
|   { code: "IT", name: "意大利" }, | ||||
|   { code: "JM", name: "牙买加" }, | ||||
|   { code: "JP", name: "日本" }, | ||||
|   { code: "JE", name: "泽西岛" }, | ||||
|   { code: "JO", name: "约旦" }, | ||||
|   { code: "KZ", name: "哈萨克斯坦" }, | ||||
|   { code: "KE", name: "肯尼亚" }, | ||||
|   { code: "KI", name: "基里巴斯" }, | ||||
|   { code: "KR", name: "韩国" }, | ||||
|   { code: "KP", name: "朝鲜" }, | ||||
|   { code: "KW", name: "科威特" }, | ||||
|   { code: "KG", name: "吉尔吉斯斯坦" }, | ||||
|   { code: "LA", name: "老挝" }, | ||||
|   { code: "LV", name: "拉脱维亚" }, | ||||
|   { code: "LB", name: "黎巴嫩" }, | ||||
|   { code: "LS", name: "莱索托" }, | ||||
|   { code: "LR", name: "利比里亚" }, | ||||
|   { code: "LY", name: "利比亚" }, | ||||
|   { code: "LI", name: "列支敦士登" }, | ||||
|   { code: "LT", name: "立陶宛" }, | ||||
|   { code: "LU", name: "卢森堡" }, | ||||
|   { code: "MO", name: "中国澳门" }, | ||||
|   { code: "MK", name: "北马其顿" }, | ||||
|   { code: "MG", name: "马达加斯加" }, | ||||
|   { code: "MW", name: "马拉维" }, | ||||
|   { code: "MY", name: "马来西亚" }, | ||||
|   { code: "MV", name: "马尔代夫" }, | ||||
|   { code: "ML", name: "马里" }, | ||||
|   { code: "MT", name: "马耳他" }, | ||||
|   { code: "MH", name: "马绍尔群岛" }, | ||||
|   { code: "MQ", name: "马提尼克" }, | ||||
|   { code: "MR", name: "毛里塔尼亚" }, | ||||
|   { code: "MU", name: "毛里求斯" }, | ||||
|   { code: "YT", name: "马约特" }, | ||||
|   { code: "MX", name: "墨西哥" }, | ||||
|   { code: "FM", name: "密克罗尼西亚" }, | ||||
|   { code: "MD", name: "摩尔多瓦" }, | ||||
|   { code: "MC", name: "摩纳哥" }, | ||||
|   { code: "MN", name: "蒙古" }, | ||||
|   { code: "ME", name: "黑山" }, | ||||
|   { code: "MS", name: "蒙特塞拉特" }, | ||||
|   { code: "MA", name: "摩洛哥" }, | ||||
|   { code: "MZ", name: "莫桑比克" }, | ||||
|   { code: "MM", name: "缅甸" }, | ||||
|   { code: "NA", name: "纳米比亚" }, | ||||
|   { code: "NR", name: "瑙鲁" }, | ||||
|   { code: "NP", name: "尼泊尔" }, | ||||
|   { code: "NL", name: "荷兰" }, | ||||
|   { code: "AN", name: "荷属安的列斯" }, | ||||
|   { code: "NC", name: "新喀里多尼亚" }, | ||||
|   { code: "NZ", name: "新西兰" }, | ||||
|   { code: "NI", name: "尼加拉瓜" }, | ||||
|   { code: "NE", name: "尼日尔" }, | ||||
|   { code: "NG", name: "尼日利亚" }, | ||||
|   { code: "NU", name: "纽埃" }, | ||||
|   { code: "NF", name: "诺福克岛" }, | ||||
|   { code: "MP", name: "北马里亚纳群岛" }, | ||||
|   { code: "NO", name: "挪威" }, | ||||
|   { code: "OM", name: "阿曼" }, | ||||
|   { code: "PK", name: "巴基斯坦" }, | ||||
|   { code: "PW", name: "帕劳" }, | ||||
|   { code: "PS", name: "巴勒斯坦" }, | ||||
|   { code: "PA", name: "巴拿马" }, | ||||
|   { code: "PG", name: "巴布亚新几内亚" }, | ||||
|   { code: "PY", name: "巴拉圭" }, | ||||
|   { code: "PE", name: "秘鲁" }, | ||||
|   { code: "PH", name: "菲律宾" }, | ||||
|   { code: "PN", name: "皮特凯恩群岛" }, | ||||
|   { code: "PL", name: "波兰" }, | ||||
|   { code: "PT", name: "葡萄牙" }, | ||||
|   { code: "PR", name: "波多黎各" }, | ||||
|   { code: "QA", name: "卡塔尔" }, | ||||
|   { code: "RE", name: "留尼汪" }, | ||||
|   { code: "RO", name: "罗马尼亚" }, | ||||
|   { code: "RU", name: "俄罗斯" }, | ||||
|   { code: "RW", name: "卢旺达" }, | ||||
|   { code: "BL", name: "圣巴泰勒米" }, | ||||
|   { code: "SH", name: "圣赫勒拿" }, | ||||
|   { code: "KN", name: "圣基茨和尼维斯" }, | ||||
|   { code: "LC", name: "圣卢西亚" }, | ||||
|   { code: "MF", name: "法属圣马丁" }, | ||||
|   { code: "PM", name: "圣皮埃尔和密克隆" }, | ||||
|   { code: "VC", name: "圣文森特和格林纳丁斯" }, | ||||
|   { code: "WS", name: "萨摩亚" }, | ||||
|   { code: "SM", name: "圣马力诺" }, | ||||
|   { code: "ST", name: "圣多美和普林西比" }, | ||||
|   { code: "SA", name: "沙特阿拉伯" }, | ||||
|   { code: "SN", name: "塞内加尔" }, | ||||
|   { code: "RS", name: "塞尔维亚" }, | ||||
|   { code: "SC", name: "塞舌尔" }, | ||||
|   { code: "SL", name: "塞拉利昂" }, | ||||
|   { code: "SG", name: "新加坡" }, | ||||
|   { code: "SK", name: "斯洛伐克" }, | ||||
|   { code: "SI", name: "斯洛文尼亚" }, | ||||
|   { code: "SB", name: "所罗门群岛" }, | ||||
|   { code: "SO", name: "索马里" }, | ||||
|   { code: "ZA", name: "南非" }, | ||||
|   { code: "GS", name: "南乔治亚和南桑威奇群岛" }, | ||||
|   { code: "ES", name: "西班牙" }, | ||||
|   { code: "LK", name: "斯里兰卡" }, | ||||
|   { code: "SD", name: "苏丹" }, | ||||
|   { code: "SR", name: "苏里南" }, | ||||
|   { code: "SJ", name: "斯瓦尔巴和扬马延" }, | ||||
|   { code: "SZ", name: "斯威士兰" }, | ||||
|   { code: "SE", name: "瑞典" }, | ||||
|   { code: "CH", name: "瑞士" }, | ||||
|   { code: "SY", name: "叙利亚" }, | ||||
|   { code: "TW", name: "中国台湾" }, | ||||
|   { code: "TJ", name: "塔吉克斯坦" }, | ||||
|   { code: "TZ", name: "坦桑尼亚" }, | ||||
|   { code: "TH", name: "泰国" }, | ||||
|   { code: "TL", name: "东帝汶" }, | ||||
|   { code: "TG", name: "多哥" }, | ||||
|   { code: "TK", name: "托克劳" }, | ||||
|   { code: "TO", name: "汤加" }, | ||||
|   { code: "TT", name: "特立尼达和多巴哥" }, | ||||
|   { code: "TN", name: "突尼斯" }, | ||||
|   { code: "TR", name: "土耳其" }, | ||||
|   { code: "TM", name: "土库曼斯坦" }, | ||||
|   { code: "TC", name: "特克斯和凯科斯群岛" }, | ||||
|   { code: "TV", name: "图瓦卢" }, | ||||
|   { code: "UG", name: "乌干达" }, | ||||
|   { code: "UA", name: "乌克兰" }, | ||||
|   { code: "AE", name: "阿联酋" }, | ||||
|   { code: "GB", name: "英国" }, | ||||
|   { code: "US", name: "美国" }, | ||||
|   { code: "UM", name: "美国本土外小岛屿" }, | ||||
|   { code: "UY", name: "乌拉圭" }, | ||||
|   { code: "UZ", name: "乌兹别克斯坦" }, | ||||
|   { code: "VU", name: "瓦努阿图" }, | ||||
|   { code: "VE", name: "委内瑞拉" }, | ||||
|   { code: "VN", name: "越南" }, | ||||
|   { code: "VG", name: "英属维尔京群岛" }, | ||||
|   { code: "VI", name: "美属维尔京群岛" }, | ||||
|   { code: "WF", name: "瓦利斯和富图纳" }, | ||||
|   { code: "EH", name: "西撒哈拉" }, | ||||
|   { code: "YE", name: "也门" }, | ||||
|   { code: "ZM", name: "赞比亚" }, | ||||
|   { code: "ZW", name: "津巴布韦" }, | ||||
| ]; | ||||
|   | ||||
							
								
								
									
										85
									
								
								src/shared/const/mediaTypes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/shared/const/mediaTypes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| // Допустимые типы и расширения файлов для медиа | ||||
| export const ALLOWED_MEDIA_TYPES = { | ||||
|   image: { | ||||
|     extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"], | ||||
|     mimeTypes: [ | ||||
|       "image/jpeg", | ||||
|       "image/png", | ||||
|       "image/gif", | ||||
|       "image/webp", | ||||
|       "image/bmp", | ||||
|       "image/svg+xml", | ||||
|     ], | ||||
|     accept: "image/*", | ||||
|   }, | ||||
|   video: { | ||||
|     extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"], | ||||
|     mimeTypes: [ | ||||
|       "video/mp4", | ||||
|       "video/webm", | ||||
|       "video/ogg", | ||||
|       "video/quicktime", | ||||
|       "video/x-msvideo", | ||||
|     ], | ||||
|     accept: "video/*", | ||||
|   }, | ||||
|   model3d: { | ||||
|     extensions: [".glb", ".gltf"], | ||||
|     mimeTypes: ["model/gltf-binary", "model/gltf+json"], | ||||
|     accept: ".glb,.gltf", | ||||
|   }, | ||||
|   panorama: { | ||||
|     extensions: [".jpg", ".jpeg", ".png"], | ||||
|     mimeTypes: ["image/jpeg", "image/png"], | ||||
|     accept: "image/*", | ||||
|   }, | ||||
| } as const; | ||||
|  | ||||
| export const getAllAllowedExtensions = (): string[] => { | ||||
|   return [ | ||||
|     ...ALLOWED_MEDIA_TYPES.image.extensions, | ||||
|     ...ALLOWED_MEDIA_TYPES.video.extensions, | ||||
|     ...ALLOWED_MEDIA_TYPES.model3d.extensions, | ||||
|   ]; | ||||
| }; | ||||
|  | ||||
| export const getAllAcceptString = (): string => { | ||||
|   return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`; | ||||
| }; | ||||
|  | ||||
| export const validateFileExtension = ( | ||||
|   file: File | ||||
| ): { valid: boolean; error?: string } => { | ||||
|   const fileName = file.name.toLowerCase(); | ||||
|   const extension = fileName.substring(fileName.lastIndexOf(".")); | ||||
|   const allowedExtensions = getAllAllowedExtensions(); | ||||
|  | ||||
|   if (!allowedExtensions.includes(extension)) { | ||||
|     return { | ||||
|       valid: false, | ||||
|       error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join( | ||||
|         ", " | ||||
|       )}`, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   return { valid: true }; | ||||
| }; | ||||
|  | ||||
| export const filterValidFiles = ( | ||||
|   files: File[] | ||||
| ): { validFiles: File[]; errors: string[] } => { | ||||
|   const validFiles: File[] = []; | ||||
|   const errors: string[] = []; | ||||
|  | ||||
|   files.forEach((file) => { | ||||
|     const validation = validateFileExtension(file); | ||||
|     if (validation.valid) { | ||||
|       validFiles.push(file); | ||||
|     } else { | ||||
|       errors.push(`${file.name}: ${validation.error}`); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   return { validFiles, errors }; | ||||
| }; | ||||
							
								
								
									
										1
									
								
								src/shared/hooks/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/shared/hooks/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * from "./useSelectedCity"; | ||||
							
								
								
									
										12
									
								
								src/shared/hooks/useSelectedCity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/shared/hooks/useSelectedCity.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { selectedCityStore } from "@shared"; | ||||
|  | ||||
| export const useSelectedCity = () => { | ||||
|   const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore; | ||||
|  | ||||
|   return { | ||||
|     selectedCity, | ||||
|     selectedCityId, | ||||
|     selectedCityName, | ||||
|     hasSelectedCity: !!selectedCity, | ||||
|   }; | ||||
| }; | ||||
| @@ -5,3 +5,4 @@ export * from "./store"; | ||||
| export * from "./const"; | ||||
| export * from "./api"; | ||||
| export * from "./modals"; | ||||
| export * from "./hooks"; | ||||
|   | ||||
							
								
								
									
										82
									
								
								src/shared/lib/gltfCacheManager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/shared/lib/gltfCacheManager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| /** | ||||
|  * Утилита для управления кешем GLTF и blob URL | ||||
|  */ | ||||
|  | ||||
| // Динамический импорт useGLTF для избежания проблем с SSR | ||||
| let useGLTF: any = null; | ||||
|  | ||||
| const initializeUseGLTF = async () => { | ||||
|   if (!useGLTF) { | ||||
|     try { | ||||
|       const drei = await import("@react-three/drei"); | ||||
|       useGLTF = drei.useGLTF; | ||||
|     } catch (error) { | ||||
|       console.warn( | ||||
|         "⚠️ GLTFCacheManager: Не удалось импортировать useGLTF", | ||||
|         error | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|   return useGLTF; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Очищает кеш GLTF для конкретного URL | ||||
|  */ | ||||
| export const clearGLTFCacheForUrl = async (url: string) => { | ||||
|   try { | ||||
|     const gltf = await initializeUseGLTF(); | ||||
|     if (gltf && gltf.clear) { | ||||
|       gltf.clear(url); | ||||
|     } | ||||
|   } catch (error) {} | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Очищает весь кеш GLTF | ||||
|  */ | ||||
| export const clearAllGLTFCache = async () => { | ||||
|   try { | ||||
|     const gltf = await initializeUseGLTF(); | ||||
|     if (gltf && gltf.clear) { | ||||
|       gltf.clear(); | ||||
|     } | ||||
|   } catch (error) {} | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Очищает blob URL из памяти браузера | ||||
|  */ | ||||
| export const revokeBlobURL = (url: string) => { | ||||
|   if (url && url.startsWith("blob:")) { | ||||
|     try { | ||||
|       URL.revokeObjectURL(url); | ||||
|     } catch (error) {} | ||||
|   } | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Комплексная очистка: blob URL + кеш GLTF | ||||
|  */ | ||||
| export const clearBlobAndGLTFCache = async (url: string) => { | ||||
|   // Сначала отзываем blob URL | ||||
|   revokeBlobURL(url); | ||||
|  | ||||
|   // Затем очищаем кеш GLTF | ||||
|   await clearGLTFCacheForUrl(url); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Очистка при смене медиа (для предотвращения конфликтов) | ||||
|  */ | ||||
| export const clearMediaTransitionCache = async ( | ||||
|   previousMediaId: string | number | null, | ||||
|   newMediaId: string | number | null, | ||||
|   newMediaType?: number | ||||
| ) => { | ||||
|   console.log(newMediaId, newMediaType); | ||||
|   // Если переключаемся с/на 3D модель, очищаем весь кеш | ||||
|   if (newMediaType === 6 || previousMediaId) { | ||||
|     await clearAllGLTFCache(); | ||||
|   } | ||||
| }; | ||||
| @@ -1,2 +1,55 @@ | ||||
| export * from "./mui/theme"; | ||||
| export * from "./DecodeJWT"; | ||||
| export * from "./gltfCacheManager"; | ||||
|  | ||||
| /** | ||||
|  * Генерирует название медиа по умолчанию в разных форматах | ||||
|  * | ||||
|  * Примеры использования: | ||||
|  * - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото" | ||||
|  * - Для достопримечательности без названия: "Название_mikhail-zamok_Фото" | ||||
|  * - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи) | ||||
|  * | ||||
|  * @param objectName - Название объекта (достопримечательности, города и т.д.) | ||||
|  * @param fileName - Название файла | ||||
|  * @param mediaType - Тип медиа (число) или название статьи | ||||
|  * @param isArticle - Флаг, указывающий что медиа добавляется к статье | ||||
|  * @returns Строка в нужном формате | ||||
|  */ | ||||
| export const generateDefaultMediaName = ( | ||||
|   objectName: string, | ||||
|   fileName: string, | ||||
|   mediaType: number | string, | ||||
|   isArticle: boolean = false | ||||
| ): string => { | ||||
|   // Убираем расширение из названия файла | ||||
|   const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); | ||||
|  | ||||
|   if (isArticle && typeof mediaType === "string") { | ||||
|     // Для статей: "Название достопримечательности_название файла_название статьи" | ||||
|     return `${objectName}_${fileNameWithoutExtension}_${mediaType}`; | ||||
|   } else if (typeof mediaType === "number") { | ||||
|     // Получаем название типа медиа | ||||
|     const mediaTypeLabels: Record<number, string> = { | ||||
|       1: "Фото", | ||||
|       2: "Видео", | ||||
|       3: "Иконка", | ||||
|       4: "Водяной знак", | ||||
|       5: "Панорама", | ||||
|       6: "3Д-модель", | ||||
|     }; | ||||
|  | ||||
|     const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа"; | ||||
|  | ||||
|     if (objectName && objectName.trim() !== "") { | ||||
|       // Если есть название объекта: "Название объекта_название файла_тип медиа" | ||||
|       return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`; | ||||
|     } else { | ||||
|       // Если нет названия объекта: "Название_название файла_тип медиа" | ||||
|       return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Fallback | ||||
|   return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; | ||||
| }; | ||||
|   | ||||
| @@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer( | ||||
|         setError(err instanceof Error ? err.message : "Failed to save media"); | ||||
|       } finally { | ||||
|         setIsLoading(false); | ||||
|         onClose(); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
| @@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer( | ||||
|             className="flex gap-4" | ||||
|             dividers | ||||
|             sx={{ | ||||
|               height: "600px", | ||||
|               display: "flex", | ||||
|               flexDirection: "column", | ||||
|               gap: 2, | ||||
| @@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer( | ||||
|                   disabled={isLoading} | ||||
|                 /> | ||||
|               </Box> | ||||
|  | ||||
|               <TextField | ||||
|                 fullWidth | ||||
|                 label="Тип медиа" | ||||
| @@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer( | ||||
|                 sx={{ width: "50%" }} | ||||
|               /> | ||||
|  | ||||
|               <Box className="flex gap-4 h-full"> | ||||
|               <Box className="flex gap-4 h-[40vh]"> | ||||
|                 <Paper | ||||
|                   elevation={2} | ||||
|                   sx={{ | ||||
| @@ -142,7 +141,6 @@ export const PreviewMediaDialog = observer( | ||||
|                     display: "flex", | ||||
|                     alignItems: "center", | ||||
|                     justifyContent: "center", | ||||
|                     minHeight: 400, | ||||
|                   }} | ||||
|                 > | ||||
|                   <MediaViewer | ||||
| @@ -151,6 +149,8 @@ export const PreviewMediaDialog = observer( | ||||
|                       media_type: media.media_type, | ||||
|                       filename: media.filename, | ||||
|                     }} | ||||
|                     className="h-full w-full object-contain" | ||||
|                     fullHeight | ||||
|                   /> | ||||
|                 </Paper> | ||||
|  | ||||
|   | ||||
| @@ -38,7 +38,9 @@ export const SelectArticleModal = observer( | ||||
|     onSelectArticle, | ||||
|     linkedArticleIds = [], | ||||
|   }: SelectArticleModalProps) => { | ||||
|     const { articles, getArticle, getArticleMedia } = articlesStore; | ||||
|     const { language } = languageStore; | ||||
|     const { articles, getArticle, getArticleMedia, getArticles } = | ||||
|       articlesStore; | ||||
|     const [searchQuery, setSearchQuery] = useState(""); | ||||
|     const [selectedArticleId, setSelectedArticleId] = useState<number | null>( | ||||
|       null | ||||
| @@ -54,6 +56,21 @@ export const SelectArticleModal = observer( | ||||
|       } | ||||
|     }, [open]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       const fetchData = async () => { | ||||
|         await getArticles("ru"); | ||||
|         await getArticles("en"); | ||||
|         await getArticles("zh"); | ||||
|       }; | ||||
|       fetchData(); | ||||
|     }, []); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       if (selectedArticleId) { | ||||
|         handleArticleClick(selectedArticleId); | ||||
|       } | ||||
|     }, [language]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       const handleKeyPress = async (event: KeyboardEvent) => { | ||||
|         if (event.key.toLowerCase() === "enter") { | ||||
| @@ -273,6 +290,25 @@ export const SelectArticleModal = observer( | ||||
|                     fontSize: "24px", | ||||
|                     fontWeight: 700, | ||||
|                     lineHeight: "120%", | ||||
|                     cursor: "pointer", | ||||
|                     "&:hover": { | ||||
|                       textDecoration: "underline", | ||||
|                     }, | ||||
|                   }} | ||||
|                   onDoubleClick={async () => { | ||||
|                     if (selectedArticleId) { | ||||
|                       const media = await authInstance.get( | ||||
|                         `/article/${selectedArticleId}/media` | ||||
|                       ); | ||||
|                       onSelectArticle( | ||||
|                         selectedArticleId, | ||||
|                         articlesStore.articleData?.heading || "", | ||||
|                         articlesStore.articleData?.body || "", | ||||
|                         media.data || [] | ||||
|                       ); | ||||
|                       onClose(); | ||||
|                       setSelectedArticleId(null); | ||||
|                     } | ||||
|                   }} | ||||
|                 > | ||||
|                   {articlesStore.articleData?.heading || "Название cтатьи"} | ||||
|   | ||||
| @@ -102,7 +102,6 @@ export const SelectMediaDialog = observer( | ||||
|       filteredMedia = filteredMedia.filter( | ||||
|         (mediaItem) => mediaItem.media_type === mediaType | ||||
|       ); | ||||
|       console.log(filteredMedia); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
| @@ -163,7 +162,13 @@ export const SelectMediaDialog = observer( | ||||
|                         }, | ||||
|                       }} | ||||
|                     > | ||||
|                       <ListItemText primary={mediaItem.media_name} /> | ||||
|                       <ListItemText | ||||
|                         primary={ | ||||
|                           mediaItem.media_name | ||||
|                             ? mediaItem.media_name | ||||
|                             : mediaItem.filename | ||||
|                         } | ||||
|                       /> | ||||
|                     </ListItemButton> | ||||
|                   ) | ||||
|                 ) | ||||
|   | ||||
| @@ -1,6 +1,12 @@ | ||||
| import { MEDIA_TYPE_LABELS, MEDIA_TYPE_VALUES, editSightStore } from "@shared"; | ||||
| import { | ||||
|   MEDIA_TYPE_LABELS, | ||||
|   MEDIA_TYPE_VALUES, | ||||
|   editSightStore, | ||||
|   generateDefaultMediaName, | ||||
|   clearBlobAndGLTFCache, | ||||
| } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useEffect, useState, useRef } from "react"; | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogTitle, | ||||
| @@ -31,7 +37,24 @@ interface UploadMediaDialogProps { | ||||
|     media_type: number; | ||||
|   }) => void; | ||||
|   afterUploadSight?: (id: string) => void; | ||||
|   hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | null; | ||||
|   hardcodeType?: | ||||
|     | "thumbnail" | ||||
|     | "watermark_lu" | ||||
|     | "watermark_rd" | ||||
|     | "image" | ||||
|     | "video_preview" | ||||
|     | null; | ||||
|   contextObjectName?: string; | ||||
|   contextType?: | ||||
|     | "sight" | ||||
|     | "city" | ||||
|     | "carrier" | ||||
|     | "country" | ||||
|     | "vehicle" | ||||
|     | "station"; | ||||
|   isArticle?: boolean; | ||||
|   articleName?: string; | ||||
|   initialFile?: File; // <--- добавлено | ||||
| } | ||||
|  | ||||
| export const UploadMediaDialog = observer( | ||||
| @@ -41,6 +64,11 @@ export const UploadMediaDialog = observer( | ||||
|     afterUpload, | ||||
|     afterUploadSight, | ||||
|     hardcodeType, | ||||
|     contextObjectName, | ||||
|  | ||||
|     isArticle, | ||||
|     articleName, | ||||
|     initialFile, // <--- добавлено | ||||
|   }: UploadMediaDialogProps) => { | ||||
|     const [isLoading, setIsLoading] = useState(false); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
| @@ -54,6 +82,41 @@ export const UploadMediaDialog = observer( | ||||
|     const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>( | ||||
|       [] | ||||
|     ); | ||||
|     const [isPreviewLoaded, setIsPreviewLoaded] = useState(false); | ||||
|     const previousMediaUrlRef = useRef<string | null>(null); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       if (initialFile) { | ||||
|         // Очищаем предыдущий blob URL если он существует | ||||
|         if ( | ||||
|           previousMediaUrlRef.current && | ||||
|           previousMediaUrlRef.current.startsWith("blob:") | ||||
|         ) { | ||||
|           clearBlobAndGLTFCache(previousMediaUrlRef.current); | ||||
|         } | ||||
|  | ||||
|         setMediaFile(initialFile); | ||||
|         setMediaFilename(initialFile.name); | ||||
|         setAvailableMediaTypes([2]); | ||||
|         setMediaType(2); | ||||
|         const newBlobUrl = URL.createObjectURL(initialFile); | ||||
|         setMediaUrl(newBlobUrl); | ||||
|         previousMediaUrlRef.current = newBlobUrl; | ||||
|         setMediaName(initialFile.name.replace(/\.[^/.]+$/, "")); | ||||
|       } | ||||
|     }, [initialFile]); | ||||
|  | ||||
|     // Очистка blob URL при размонтировании компонента | ||||
|     useEffect(() => { | ||||
|       return () => { | ||||
|         if ( | ||||
|           previousMediaUrlRef.current && | ||||
|           previousMediaUrlRef.current.startsWith("blob:") | ||||
|         ) { | ||||
|           clearBlobAndGLTFCache(previousMediaUrlRef.current); | ||||
|         } | ||||
|       }; | ||||
|     }, []); // Пустой массив зависимостей - выполняется только при размонтировании | ||||
|  | ||||
|     useEffect(() => { | ||||
|       if (fileToUpload) { | ||||
| @@ -66,7 +129,11 @@ export const UploadMediaDialog = observer( | ||||
|             setAvailableMediaTypes([6]); | ||||
|             setMediaType(6); | ||||
|           } | ||||
|           if (["jpg", "jpeg", "png", "gif"].includes(extension)) { | ||||
|           if ( | ||||
|             ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( | ||||
|               extension | ||||
|             ) | ||||
|           ) { | ||||
|             // Для изображений доступны все типы кроме видео | ||||
|             setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель | ||||
|             setMediaType(1); // По умолчанию Фото | ||||
| @@ -76,14 +143,112 @@ export const UploadMediaDialog = observer( | ||||
|             setMediaType(2); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // Генерируем название по умолчанию если есть контекст | ||||
|         if (fileToUpload.name) { | ||||
|           let defaultName = ""; | ||||
|  | ||||
|           if (isArticle && articleName && contextObjectName) { | ||||
|             // Для статей: "Название достопримечательности_название файла_название статьи" | ||||
|             defaultName = generateDefaultMediaName( | ||||
|               contextObjectName, | ||||
|               fileToUpload.name, | ||||
|               articleName, | ||||
|               true | ||||
|             ); | ||||
|           } else if (contextObjectName && contextObjectName.trim() !== "") { | ||||
|             // Для обычных медиа с названием объекта | ||||
|             const currentMediaType = hardcodeType | ||||
|               ? MEDIA_TYPE_VALUES[hardcodeType] | ||||
|               : 1; // По умолчанию фото | ||||
|             defaultName = generateDefaultMediaName( | ||||
|               contextObjectName, | ||||
|               fileToUpload.name, | ||||
|               currentMediaType, | ||||
|               false | ||||
|             ); | ||||
|           } else { | ||||
|             // Для медиа без названия объекта | ||||
|             const currentMediaType = hardcodeType | ||||
|               ? MEDIA_TYPE_VALUES[hardcodeType] | ||||
|               : 1; // По умолчанию фото | ||||
|             defaultName = generateDefaultMediaName( | ||||
|               "", | ||||
|               fileToUpload.name, | ||||
|               currentMediaType, | ||||
|               false | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|           setMediaName(defaultName); | ||||
|         } | ||||
|       } | ||||
|     }, [fileToUpload]); | ||||
|     }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); | ||||
|  | ||||
|     // Обновляем название при изменении типа медиа | ||||
|     useEffect(() => { | ||||
|       if (mediaFilename && mediaType > 0) { | ||||
|         let defaultName = ""; | ||||
|  | ||||
|         if (isArticle && articleName && contextObjectName) { | ||||
|           // Для статей: "Название достопримечательности_название файла_название статьи" | ||||
|           defaultName = generateDefaultMediaName( | ||||
|             contextObjectName, | ||||
|             mediaFilename, | ||||
|             articleName, | ||||
|             true | ||||
|           ); | ||||
|         } else if (contextObjectName && contextObjectName.trim() !== "") { | ||||
|           // Для обычных медиа с названием объекта | ||||
|           const currentMediaType = hardcodeType | ||||
|             ? MEDIA_TYPE_VALUES[hardcodeType] | ||||
|             : mediaType; | ||||
|           defaultName = generateDefaultMediaName( | ||||
|             contextObjectName, | ||||
|             mediaFilename, | ||||
|             currentMediaType, | ||||
|             false | ||||
|           ); | ||||
|         } else { | ||||
|           // Для медиа без названия объекта | ||||
|           const currentMediaType = hardcodeType | ||||
|             ? MEDIA_TYPE_VALUES[hardcodeType] | ||||
|             : mediaType; | ||||
|           defaultName = generateDefaultMediaName( | ||||
|             "", | ||||
|             mediaFilename, | ||||
|             currentMediaType, | ||||
|             false | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         setMediaName(defaultName); | ||||
|       } | ||||
|     }, [ | ||||
|       mediaType, | ||||
|       contextObjectName, | ||||
|       mediaFilename, | ||||
|       hardcodeType, | ||||
|       isArticle, | ||||
|       articleName, | ||||
|     ]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       if (mediaFile) { | ||||
|         setMediaUrl(URL.createObjectURL(mediaFile as Blob)); | ||||
|         // Очищаем предыдущий blob URL и кеш GLTF если он существует | ||||
|         if ( | ||||
|           previousMediaUrlRef.current && | ||||
|           previousMediaUrlRef.current.startsWith("blob:") | ||||
|         ) { | ||||
|           clearBlobAndGLTFCache(previousMediaUrlRef.current); | ||||
|         } | ||||
|  | ||||
|         const newBlobUrl = URL.createObjectURL(mediaFile as Blob); | ||||
|         setMediaUrl(newBlobUrl); | ||||
|         previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref | ||||
|         setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла | ||||
|       } | ||||
|     }, [mediaFile]); | ||||
|     }, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания | ||||
|  | ||||
|     // const fileFormat = useEffect(() => { | ||||
|     //   const handleKeyPress = (event: KeyboardEvent) => { | ||||
| @@ -120,6 +285,10 @@ export const UploadMediaDialog = observer( | ||||
|           } | ||||
|         } | ||||
|         setSuccess(true); | ||||
|         // Закрываем модальное окно после успешного сохранения | ||||
|         setTimeout(() => { | ||||
|           handleClose(); | ||||
|         }, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе | ||||
|       } catch (err) { | ||||
|         setError(err instanceof Error ? err.message : "Failed to save media"); | ||||
|       } finally { | ||||
| @@ -128,8 +297,20 @@ export const UploadMediaDialog = observer( | ||||
|     }; | ||||
|  | ||||
|     const handleClose = () => { | ||||
|       // Очищаем blob URL и кеш GLTF при закрытии диалога | ||||
|       if ( | ||||
|         previousMediaUrlRef.current && | ||||
|         previousMediaUrlRef.current.startsWith("blob:") | ||||
|       ) { | ||||
|         clearBlobAndGLTFCache(previousMediaUrlRef.current); | ||||
|       } | ||||
|  | ||||
|       setError(null); | ||||
|       setSuccess(false); | ||||
|       setMediaUrl(null); | ||||
|       setMediaFile(null); | ||||
|       setIsPreviewLoaded(false); | ||||
|       previousMediaUrlRef.current = null; // Очищаем ref | ||||
|       onClose(); | ||||
|     }; | ||||
|  | ||||
| @@ -141,7 +322,6 @@ export const UploadMediaDialog = observer( | ||||
|             className="flex gap-4" | ||||
|             dividers | ||||
|             sx={{ | ||||
|               height: "600px", | ||||
|               display: "flex", | ||||
|               flexDirection: "column", | ||||
|               gap: 2, | ||||
| @@ -188,7 +368,7 @@ export const UploadMediaDialog = observer( | ||||
|                 </Select> | ||||
|               </FormControl> | ||||
|  | ||||
|               <Box className="flex gap-4 h-full"> | ||||
|               <Box className="flex gap-4 h-[40vh]"> | ||||
|                 <Paper | ||||
|                   elevation={2} | ||||
|                   sx={{ | ||||
| @@ -197,28 +377,52 @@ export const UploadMediaDialog = observer( | ||||
|                     display: "flex", | ||||
|                     alignItems: "center", | ||||
|                     justifyContent: "center", | ||||
|                     minHeight: 400, | ||||
|                     height: "100%", | ||||
|                     position: "relative", | ||||
|                   }} | ||||
|                 > | ||||
|                   {/* <MediaViewer | ||||
|                     media={{ | ||||
|                       id: "", | ||||
|                       media_type: mediaType, | ||||
|                       filename: mediaFilename, | ||||
|                     }} | ||||
|                   /> */} | ||||
|                   {!isPreviewLoaded && mediaUrl && ( | ||||
|                     <Box | ||||
|                       sx={{ | ||||
|                         position: "absolute", | ||||
|                         top: "50%", | ||||
|                         left: "50%", | ||||
|                         transform: "translate(-50%, -50%)", | ||||
|                         zIndex: 1, | ||||
|                       }} | ||||
|                     > | ||||
|                       <CircularProgress /> | ||||
|                     </Box> | ||||
|                   )} | ||||
|                   {mediaType == 2 && mediaUrl && ( | ||||
|                     <video | ||||
|                       src={mediaUrl} | ||||
|                       autoPlay | ||||
|                       muted | ||||
|                       loop | ||||
|                       controls | ||||
|                       style={{ maxWidth: "100%", maxHeight: "100%" }} | ||||
|                       onLoadedData={() => setIsPreviewLoaded(true)} | ||||
|                       onError={() => setIsPreviewLoaded(true)} | ||||
|                     /> | ||||
|                   )} | ||||
|                   {mediaType === 6 && mediaUrl && ( | ||||
|                     <ModelViewer3D fileUrl={mediaUrl} height="100%" /> | ||||
|                     <ModelViewer3D | ||||
|                       fileUrl={mediaUrl} | ||||
|                       height="100%" | ||||
|                       onLoad={() => setIsPreviewLoaded(true)} | ||||
|                     /> | ||||
|                   )} | ||||
|                   {mediaType !== 6 && mediaType !== 2 && mediaUrl && ( | ||||
|                     <img | ||||
|                       src={mediaUrl ?? ""} | ||||
|                       alt="Uploaded media" | ||||
|                       style={{ | ||||
|                         maxWidth: "100%", | ||||
|                         maxHeight: "100%", | ||||
|                         height: "100%", | ||||
|                         objectFit: "contain", | ||||
|                       }} | ||||
|                       onLoad={() => setIsPreviewLoaded(true)} | ||||
|                       onError={() => setIsPreviewLoaded(true)} | ||||
|                     /> | ||||
|                   )} | ||||
|                 </Paper> | ||||
| @@ -226,18 +430,31 @@ export const UploadMediaDialog = observer( | ||||
|                 <Box className="flex flex-col gap-2 self-end"> | ||||
|                   <Button | ||||
|                     variant="contained" | ||||
|                     color="success" | ||||
|                     sx={{ | ||||
|                       backgroundColor: isLoading ? "#9e9e9e" : "#4caf50", | ||||
|                       "&:hover": { | ||||
|                         backgroundColor: isLoading ? "#9e9e9e" : "#45a049", | ||||
|                       }, | ||||
|                     }} | ||||
|                     startIcon={ | ||||
|                       isLoading ? ( | ||||
|                         <CircularProgress size={16} /> | ||||
|                         <CircularProgress size={16} color="inherit" /> | ||||
|                       ) : ( | ||||
|                         <Save size={16} /> | ||||
|                       ) | ||||
|                     } | ||||
|                     onClick={handleSave} | ||||
|                     disabled={isLoading || (!mediaName && !mediaFilename)} | ||||
|                     disabled={ | ||||
|                       isLoading || | ||||
|                       (!mediaName && !mediaFilename) || | ||||
|                       !isPreviewLoaded | ||||
|                     } | ||||
|                   > | ||||
|                     Сохранить | ||||
|                     {isLoading | ||||
|                       ? "Сохранение..." | ||||
|                       : !isPreviewLoaded | ||||
|                       ? "Загрузка превью..." | ||||
|                       : "Сохранить"} | ||||
|                   </Button> | ||||
|                 </Box> | ||||
|               </Box> | ||||
|   | ||||
| @@ -1,4 +1,10 @@ | ||||
| import { authInstance, editSightStore, Language, languageStore } from "@shared"; | ||||
| import { | ||||
|   authInstance, | ||||
|   editSightStore, | ||||
|   Language, | ||||
|   languageStore, | ||||
|   languageInstance, | ||||
| } from "@shared"; | ||||
| import { computed, makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| export type Article = { | ||||
| @@ -6,6 +12,18 @@ export type Article = { | ||||
|   heading: string; | ||||
|   body: string; | ||||
|   service_name: string; | ||||
|   ru?: { | ||||
|     heading: string; | ||||
|     body: string; | ||||
|   }; | ||||
|   en?: { | ||||
|     heading: string; | ||||
|     body: string; | ||||
|   }; | ||||
|   zh?: { | ||||
|     heading: string; | ||||
|     body: string; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| type Media = { | ||||
| @@ -91,7 +109,8 @@ class ArticlesStore { | ||||
|  | ||||
|   getArticles = async (language: Language) => { | ||||
|     this.articleLoading = true; | ||||
|     const response = await authInstance.get("/article"); | ||||
|  | ||||
|     const response = await languageInstance(language).get("/article"); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.articles[language] = response.data; | ||||
| @@ -99,13 +118,27 @@ class ArticlesStore { | ||||
|     this.articleLoading = false; | ||||
|   }; | ||||
|  | ||||
|   getArticle = async (id: number) => { | ||||
|   getArticle = async (id: number, language?: Language) => { | ||||
|     this.articleLoading = true; | ||||
|     const response = await authInstance.get(`/article/${id}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.articleData = response.data; | ||||
|     }); | ||||
|     let response: any; | ||||
|     if (language) { | ||||
|       response = await languageInstance(language).get(`/article/${id}`); | ||||
|       runInAction(() => { | ||||
|         if (!this.articleData) { | ||||
|           this.articleData = { id, heading: "", body: "", service_name: "" }; | ||||
|         } | ||||
|         this.articleData[language] = { | ||||
|           heading: response.data.heading, | ||||
|           body: response.data.body, | ||||
|         }; | ||||
|       }); | ||||
|     } else { | ||||
|       response = await authInstance.get(`/article/${id}`); | ||||
|       runInAction(() => { | ||||
|         this.articleData = response.data; | ||||
|       }); | ||||
|     } | ||||
|     return response; | ||||
|     this.articleLoading = false; | ||||
|   }; | ||||
|  | ||||
| @@ -137,6 +170,20 @@ class ArticlesStore { | ||||
|     } | ||||
|     return null; | ||||
|   }); | ||||
|  | ||||
|   deleteArticles = async (ids: number[]) => { | ||||
|     for (const id of ids) { | ||||
|       await authInstance.delete(`/article/${id}`); | ||||
|     } | ||||
|  | ||||
|     for (const id of ["ru", "en", "zh"] as Language[]) { | ||||
|       runInAction(() => { | ||||
|         this.articleList[id].data = this.articleList[id].data.filter( | ||||
|           (article) => !ids.includes(article.id) | ||||
|         ); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export const articlesStore = new ArticlesStore(); | ||||
|   | ||||
| @@ -55,7 +55,11 @@ class AuthStore { | ||||
|  | ||||
|       runInAction(() => { | ||||
|         this.setAuthToken(data.token); | ||||
|         this.payload = response.data; | ||||
|         this.payload = { | ||||
|           ...response.data.user, | ||||
|           // @ts-ignore | ||||
|           user_id: response.data.user.id, | ||||
|         }; | ||||
|         this.error = null; | ||||
|       }); | ||||
|     } catch (error) { | ||||
|   | ||||
| @@ -1,4 +1,10 @@ | ||||
| import { authInstance } from "@shared"; | ||||
| import { | ||||
|   authInstance, | ||||
|   cityStore, | ||||
|   languageStore, | ||||
|   languageInstance, | ||||
|   Language, | ||||
| } from "@shared"; | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| export type Carrier = { | ||||
| @@ -9,22 +15,45 @@ export type Carrier = { | ||||
|   city: string; | ||||
|   city_id: number; | ||||
|   logo: string; | ||||
|   main_color: string; | ||||
|   left_color: string; | ||||
|   right_color: string; | ||||
|   // main_color: string; | ||||
|   // left_color: string; | ||||
|   // right_color: string; | ||||
| }; | ||||
|  | ||||
| type Carriers = { | ||||
| type CarrierData = { | ||||
|   data: Carrier[]; | ||||
|   loaded: boolean; | ||||
| }; | ||||
|  | ||||
| type CashedCarrier = Record<number, Carrier>; | ||||
| type Carriers = { | ||||
|   ru: CarrierData; | ||||
|   en: CarrierData; | ||||
|   zh: CarrierData; | ||||
| }; | ||||
|  | ||||
| type CashedCarrier = Record< | ||||
|   number, | ||||
|   { | ||||
|     ru: Carrier | null; | ||||
|     en: Carrier | null; | ||||
|     zh: Carrier | null; | ||||
|   } | ||||
| >; | ||||
|  | ||||
| class CarrierStore { | ||||
|   carriers: Carriers = { | ||||
|     data: [], | ||||
|     loaded: false, | ||||
|     ru: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     en: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     zh: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|   }; | ||||
|   carrier: CashedCarrier = {}; | ||||
|  | ||||
| @@ -32,14 +61,14 @@ class CarrierStore { | ||||
|     makeAutoObservable(this); | ||||
|   } | ||||
|  | ||||
|   getCarriers = async () => { | ||||
|     if (this.carriers.loaded) return; | ||||
|   getCarriers = async (language: Language) => { | ||||
|     if (this.carriers[language as keyof Carriers].loaded) return; | ||||
|  | ||||
|     const response = await authInstance.get("/carrier"); | ||||
|     const response = await languageInstance(language).get("/carrier"); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.carriers.data = response.data; | ||||
|       this.carriers.loaded = true; | ||||
|       this.carriers[language as keyof Carriers].data = response.data; | ||||
|       this.carriers[language as keyof Carriers].loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -47,113 +76,231 @@ class CarrierStore { | ||||
|     await authInstance.delete(`/carrier/${id}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.carriers.data = this.carriers.data.filter( | ||||
|         (carrier) => carrier.id !== id | ||||
|       ); | ||||
|       for (const language of ["ru", "en", "zh"] as const) { | ||||
|         this.carriers[language].data = this.carriers[language].data.filter( | ||||
|           (carrier: Carrier) => carrier.id !== id | ||||
|         ); | ||||
|       } | ||||
|       delete this.carrier[id]; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   getCarrier = async (id: number) => { | ||||
|     if (this.carrier[id]) return; | ||||
|     const response = await authInstance.get(`/carrier/${id}`); | ||||
|     if (this.carrier[id]?.ru && this.carrier[id]?.en && this.carrier[id]?.zh) | ||||
|       return; | ||||
|  | ||||
|     const ruResponse = await languageInstance("ru").get(`/carrier/${id}`); | ||||
|     const enResponse = await languageInstance("en").get(`/carrier/${id}`); | ||||
|     const zhResponse = await languageInstance("zh").get(`/carrier/${id}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       if (!this.carrier[id]) { | ||||
|         this.carrier[id] = { | ||||
|           id: 0, | ||||
|           short_name: "", | ||||
|           full_name: "", | ||||
|           slogan: "", | ||||
|           city: "", | ||||
|           city_id: 0, | ||||
|           logo: "", | ||||
|           main_color: "", | ||||
|           left_color: "", | ||||
|           right_color: "", | ||||
|           ru: null, | ||||
|           en: null, | ||||
|           zh: null, | ||||
|         }; | ||||
|       } | ||||
|       this.carrier[id] = response.data; | ||||
|       this.carrier[id].ru = ruResponse.data; | ||||
|       this.carrier[id].en = enResponse.data; | ||||
|       this.carrier[id].zh = zhResponse.data; | ||||
|     }); | ||||
|     return response.data; | ||||
|     return this.carrier[id]; | ||||
|   }; | ||||
|  | ||||
|   createCarrier = async ( | ||||
|   createCarrierData = { | ||||
|     city_id: 0, | ||||
|     logo: "", | ||||
|     ru: { | ||||
|       full_name: "", | ||||
|       short_name: "", | ||||
|       slogan: "", | ||||
|     }, | ||||
|     en: { | ||||
|       full_name: "", | ||||
|       short_name: "", | ||||
|       slogan: "", | ||||
|     }, | ||||
|     zh: { | ||||
|       full_name: "", | ||||
|       short_name: "", | ||||
|       slogan: "", | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   setCreateCarrierData = ( | ||||
|     fullName: string, | ||||
|     shortName: string, | ||||
|     city: string, | ||||
|     cityId: number, | ||||
|     main_color: string, | ||||
|     left_color: string, | ||||
|     right_color: string, | ||||
|     slogan: string, | ||||
|     logoId: string | ||||
|     logoId: string, | ||||
|     language: Language | ||||
|   ) => { | ||||
|     const response = await authInstance.post("/carrier", { | ||||
|     this.createCarrierData.city_id = cityId; | ||||
|     this.createCarrierData.logo = logoId; | ||||
|     this.createCarrierData[language] = { | ||||
|       full_name: fullName, | ||||
|       short_name: shortName, | ||||
|       city, | ||||
|       city_id: cityId, | ||||
|       main_color, | ||||
|       left_color, | ||||
|       right_color, | ||||
|       slogan, | ||||
|       logo: logoId, | ||||
|     }); | ||||
|       slogan: slogan, | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   createCarrier = async () => { | ||||
|     const { language } = languageStore; | ||||
|     const cityName = | ||||
|       cityStore.cities[language].data.find( | ||||
|         (city) => city.id === this.createCarrierData.city_id | ||||
|       )?.name || ""; | ||||
|  | ||||
|     const payload = { | ||||
|       full_name: this.createCarrierData[language].full_name, | ||||
|       short_name: this.createCarrierData[language].short_name, | ||||
|       city: cityName, | ||||
|       city_id: this.createCarrierData.city_id, | ||||
|       slogan: this.createCarrierData[language].slogan, | ||||
|       ...(this.createCarrierData.logo | ||||
|         ? { logo: this.createCarrierData.logo } | ||||
|         : {}), | ||||
|     }; | ||||
|  | ||||
|     const response = await languageInstance(language).post("/carrier", payload); | ||||
|  | ||||
|     const carrierId = response.data.id; | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.carriers.data.push(response.data); | ||||
|       this.carriers[language].data.push(response.data); | ||||
|     }); | ||||
|  | ||||
|     // Create translations for other languages | ||||
|     for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { | ||||
|       const patchPayload = { | ||||
|         // @ts-ignore | ||||
|         full_name: this.createCarrierData[lang as any].full_name as string, | ||||
|         // @ts-ignore | ||||
|         short_name: this.createCarrierData[lang as any].short_name as string, | ||||
|         city: cityName, | ||||
|         city_id: this.createCarrierData.city_id, | ||||
|         // @ts-ignore | ||||
|         slogan: this.createCarrierData[lang as any].slogan as string, | ||||
|         ...(this.createCarrierData.logo | ||||
|           ? { logo: this.createCarrierData.logo } | ||||
|           : {}), | ||||
|       }; | ||||
|  | ||||
|       const response = await languageInstance(lang as Language).patch( | ||||
|         `/carrier/${carrierId}`, | ||||
|         patchPayload | ||||
|       ); | ||||
|  | ||||
|       runInAction(() => { | ||||
|         this.carriers[lang as keyof Carriers].data.push(response.data); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     this.createCarrierData = { | ||||
|       city_id: 0, | ||||
|       logo: "", | ||||
|       ru: { | ||||
|         full_name: "", | ||||
|         short_name: "", | ||||
|         slogan: "", | ||||
|       }, | ||||
|       en: { | ||||
|         full_name: "", | ||||
|         short_name: "", | ||||
|         slogan: "", | ||||
|       }, | ||||
|       zh: { | ||||
|         full_name: "", | ||||
|         short_name: "", | ||||
|         slogan: "", | ||||
|       }, | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   editCarrierData = { | ||||
|     full_name: "", | ||||
|     short_name: "", | ||||
|     city: "", | ||||
|     ru: { | ||||
|       full_name: "", | ||||
|       short_name: "", | ||||
|  | ||||
|       // main_color: "", | ||||
|       // left_color: "", | ||||
|       // right_color: "", | ||||
|       slogan: "", | ||||
|     }, | ||||
|     en: { | ||||
|       full_name: "", | ||||
|       short_name: "", | ||||
|  | ||||
|       // main_color: "", | ||||
|       // left_color: "", | ||||
|       // right_color: "", | ||||
|       slogan: "", | ||||
|     }, | ||||
|     city_id: 0, | ||||
|     main_color: "", | ||||
|     left_color: "", | ||||
|     right_color: "", | ||||
|     slogan: "", | ||||
|     logo: "", | ||||
|     zh: { | ||||
|       full_name: "", | ||||
|       short_name: "", | ||||
|  | ||||
|       // main_color: "", | ||||
|       // left_color: "", | ||||
|       // right_color: "", | ||||
|       slogan: "", | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   setEditCarrierData = ( | ||||
|     fullName: string, | ||||
|     shortName: string, | ||||
|     city: string, | ||||
|     cityId: number, | ||||
|     main_color: string, | ||||
|     left_color: string, | ||||
|     right_color: string, | ||||
|     // main_color: string, | ||||
|     // left_color: string, | ||||
|     // right_color: string, | ||||
|     slogan: string, | ||||
|     logoId: string | ||||
|     logoId: string, | ||||
|     language: Language | ||||
|   ) => { | ||||
|     this.editCarrierData = { | ||||
|     this.editCarrierData.city_id = cityId; | ||||
|     this.editCarrierData.logo = logoId; | ||||
|     this.editCarrierData[language] = { | ||||
|       full_name: fullName, | ||||
|       short_name: shortName, | ||||
|       city, | ||||
|       city_id: cityId, | ||||
|       main_color: main_color, | ||||
|       left_color: left_color, | ||||
|       right_color: right_color, | ||||
|       // main_color: main_color, | ||||
|       // left_color: left_color, | ||||
|       // right_color: right_color, | ||||
|       slogan: slogan, | ||||
|       logo: logoId, | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   editCarrier = async (id: number) => { | ||||
|     const response = await authInstance.patch( | ||||
|       `/carrier/${id}`, | ||||
|       this.editCarrierData | ||||
|     ); | ||||
|     const { language } = languageStore; | ||||
|     const cityName = | ||||
|       cityStore.cities[language].data.find( | ||||
|         (city) => city.id === this.editCarrierData.city_id | ||||
|       )?.name || ""; | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.carriers.data = this.carriers.data.map((carrier) => | ||||
|         carrier.id === id ? { ...carrier, ...response.data } : carrier | ||||
|       ); | ||||
|     for (const lang of ["ru", "en", "zh"] as const) { | ||||
|       const response = await languageInstance(lang).patch(`/carrier/${id}`, { | ||||
|         ...this.editCarrierData[lang], | ||||
|         city: cityName, | ||||
|         city_id: this.editCarrierData.city_id, | ||||
|         ...(this.editCarrierData.logo | ||||
|           ? { logo: this.editCarrierData.logo } | ||||
|           : {}), | ||||
|       }); | ||||
|  | ||||
|       this.carrier[id] = response.data; | ||||
|     }); | ||||
|       runInAction(() => { | ||||
|         if (this.carrier[id]) { | ||||
|           this.carrier[id][lang] = response.data; | ||||
|         } | ||||
|  | ||||
|         this.carriers[lang].data = this.carriers[lang].data.map( | ||||
|           (carrier: Carrier) => | ||||
|             carrier.id === id ? { ...carrier, ...response.data } : carrier | ||||
|         ); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { | ||||
|   Language, | ||||
|   languageStore, | ||||
|   countryStore, | ||||
|   CashedCountries, | ||||
| } from "@shared"; | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| @@ -16,9 +17,18 @@ export type City = { | ||||
| }; | ||||
|  | ||||
| export type CashedCities = { | ||||
|   ru: City[]; | ||||
|   en: City[]; | ||||
|   zh: City[]; | ||||
|   ru: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   en: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   zh: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export type CashedCity = { | ||||
| @@ -29,9 +39,18 @@ export type CashedCity = { | ||||
|  | ||||
| class CityStore { | ||||
|   cities: CashedCities = { | ||||
|     ru: [], | ||||
|     en: [], | ||||
|     zh: [], | ||||
|     ru: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     en: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     zh: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   city: Record<string, CashedCity> = {}; | ||||
| @@ -40,25 +59,37 @@ class CityStore { | ||||
|     makeAutoObservable(this); | ||||
|   } | ||||
|  | ||||
|   ruCities: City[] = []; | ||||
|   ruCities: { | ||||
|     data: City[]; | ||||
|     loaded: boolean; | ||||
|   } = { | ||||
|     data: [], | ||||
|     loaded: false, | ||||
|   }; | ||||
|  | ||||
|   getRuCities = async () => { | ||||
|     if (this.ruCities.loaded) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await languageInstance("ru").get(`/city`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.ruCities = response.data; | ||||
|       this.ruCities.data = response.data; | ||||
|       this.ruCities.loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   getCities = async (language: keyof CashedCities) => { | ||||
|     if (this.cities[language] && this.cities[language].length > 0) { | ||||
|     if (this.cities[language].loaded) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/city`); | ||||
|     const response = await languageInstance(language).get(`/city`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.cities[language] = response.data; | ||||
|       this.cities[language].data = response.data; | ||||
|       this.cities[language].loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -67,7 +98,7 @@ class CityStore { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/city/${code}`); | ||||
|     const response = await languageInstance(language).get(`/city/${code}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       if (!this.city[code]) { | ||||
| @@ -83,19 +114,22 @@ class CityStore { | ||||
|     return response.data; | ||||
|   }; | ||||
|  | ||||
|   deleteCity = async (code: string, language: keyof CashedCities) => { | ||||
|   deleteCity = async (code: string) => { | ||||
|     await authInstance.delete(`/city/${code}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.cities[language] = this.cities[language].filter( | ||||
|         (city) => city.country_code !== code | ||||
|       ); | ||||
|       this.city[code][language] = null; | ||||
|       for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) { | ||||
|         this.cities[secondaryLanguage].data = this.cities[ | ||||
|           secondaryLanguage | ||||
|         ].data.filter((city) => city.id !== Number(code)); | ||||
|         if (this.city[code]) { | ||||
|           this.city[code][secondaryLanguage] = null; | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   createCityData = { | ||||
|     country: "", | ||||
|     country_code: "", | ||||
|     arms: "", | ||||
|     ru: { | ||||
| @@ -111,14 +145,12 @@ class CityStore { | ||||
|  | ||||
|   setCreateCityData = ( | ||||
|     name: string, | ||||
|     country: string, | ||||
|     country_code: string, | ||||
|     arms: string, | ||||
|     language: keyof CashedCities | ||||
|   ) => { | ||||
|     this.createCityData = { | ||||
|       ...this.createCityData, | ||||
|       country: country, | ||||
|       country_code: country_code, | ||||
|       arms: arms, | ||||
|       [language]: { | ||||
| @@ -127,73 +159,91 @@ class CityStore { | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   createCity = async () => { | ||||
|     const { language } = languageStore; | ||||
|     const { country, country_code, arms } = this.createCityData; | ||||
|     const { name } = this.createCityData[language as keyof CashedCities]; | ||||
|   async createCity() { | ||||
|     const language = languageStore.language as Language; | ||||
|     const { country_code, arms } = this.createCityData; | ||||
|     const { name } = this.createCityData[language]; | ||||
|  | ||||
|     if (name && country && country_code && arms) { | ||||
|       const cityResponse = await languageInstance(language as Language).post( | ||||
|     if (!name || !country_code) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       // Create city in primary language | ||||
|       const cityPayload = { | ||||
|         name, | ||||
|         country: | ||||
|           countryStore.countries[language as keyof CashedCountries]?.data.find( | ||||
|             (c) => c.code === country_code | ||||
|           )?.name || "", | ||||
|         country_code, | ||||
|         ...(arms ? { arms } : {}), | ||||
|       }; | ||||
|  | ||||
|       const cityResponse = await languageInstance(language).post( | ||||
|         "/city", | ||||
|         { | ||||
|           name: name, | ||||
|           country: country, | ||||
|           country_code: country_code, | ||||
|           arms: arms, | ||||
|         } | ||||
|         cityPayload | ||||
|       ); | ||||
|  | ||||
|       runInAction(() => { | ||||
|         this.cities[language as keyof CashedCities] = [ | ||||
|           ...this.cities[language as keyof CashedCities], | ||||
|           cityResponse.data, | ||||
|         ]; | ||||
|       }); | ||||
|       const cityId = cityResponse.data.id; | ||||
|  | ||||
|       for (const secondaryLanguage of ["ru", "en", "zh"].filter( | ||||
|       // Create/update other language versions | ||||
|       for (const secondaryLanguage of (["ru", "en", "zh"] as Language[]).filter( | ||||
|         (l) => l !== language | ||||
|       )) { | ||||
|         const { name } = | ||||
|           this.createCityData[secondaryLanguage as keyof CashedCities]; | ||||
|         const { name: secondaryName } = this.createCityData[secondaryLanguage]; | ||||
|  | ||||
|         const patchResponse = await languageInstance( | ||||
|           secondaryLanguage as Language | ||||
|         ).patch(`/city/${cityResponse.data.id}`, { | ||||
|           name: name, | ||||
|           country: country, | ||||
|           country_code: country_code, | ||||
|           arms: arms, | ||||
|         }); | ||||
|         // Get country name in secondary language | ||||
|         const countryName = | ||||
|           countryStore.countries[secondaryLanguage]?.data.find( | ||||
|             (c) => c.code === country_code | ||||
|           )?.name || ""; | ||||
|  | ||||
|         const patchPayload = { | ||||
|           name: secondaryName || "", | ||||
|           country: countryName, | ||||
|           country_code: country_code || "", | ||||
|           ...(arms ? { arms } : {}), | ||||
|         }; | ||||
|  | ||||
|         const patchResponse = await languageInstance(secondaryLanguage).patch( | ||||
|           `/city/${cityId}`, | ||||
|           patchPayload | ||||
|         ); | ||||
|  | ||||
|         runInAction(() => { | ||||
|           this.cities[secondaryLanguage as keyof CashedCities] = [ | ||||
|             ...this.cities[secondaryLanguage as keyof CashedCities], | ||||
|           this.cities[secondaryLanguage].data = [ | ||||
|             ...this.cities[secondaryLanguage].data, | ||||
|             patchResponse.data, | ||||
|           ]; | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.createCityData = { | ||||
|         country: "", | ||||
|         country_code: "", | ||||
|         arms: "", | ||||
|         ru: { | ||||
|           name: "", | ||||
|         }, | ||||
|         en: { | ||||
|           name: "", | ||||
|         }, | ||||
|         zh: { | ||||
|           name: "", | ||||
|         }, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|       // Update primary language data | ||||
|       runInAction(() => { | ||||
|         this.cities[language].data = [ | ||||
|           ...this.cities[language].data, | ||||
|           cityResponse.data, | ||||
|         ]; | ||||
|       }); | ||||
|  | ||||
|       // Reset form data | ||||
|       runInAction(() => { | ||||
|         this.createCityData = { | ||||
|           country_code: "", | ||||
|           arms: "", | ||||
|           ru: { name: "" }, | ||||
|           en: { name: "" }, | ||||
|           zh: { name: "" }, | ||||
|         }; | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       console.error("Error creating city:", error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   editCityData = { | ||||
|     country: "", | ||||
|     country_code: "", | ||||
|     arms: "", | ||||
|     ru: { | ||||
| @@ -209,14 +259,12 @@ class CityStore { | ||||
|  | ||||
|   setEditCityData = ( | ||||
|     name: string, | ||||
|     country: string, | ||||
|     country_code: string, | ||||
|     arms: string, | ||||
|     language: keyof CashedCities | ||||
|   ) => { | ||||
|     this.editCityData = { | ||||
|       ...this.editCityData, | ||||
|       country: country, | ||||
|       country_code: country_code, | ||||
|       arms: arms, | ||||
|  | ||||
| @@ -232,45 +280,43 @@ class CityStore { | ||||
|       const { name } = this.editCityData[language as keyof CashedCities]; | ||||
|       const { countries } = countryStore; | ||||
|  | ||||
|       const country = countries[language as keyof CashedCities].find( | ||||
|       const country = countries[language as keyof CashedCities].data.find( | ||||
|         (country) => country.code === country_code | ||||
|       ); | ||||
|  | ||||
|       if (name) { | ||||
|         await languageInstance(language as Language).patch(`/city/${code}`, { | ||||
|           name, | ||||
|           country: country?.name || "", | ||||
|           country_code: country_code, | ||||
|           arms, | ||||
|         }); | ||||
|       await languageInstance(language as Language).patch(`/city/${code}`, { | ||||
|         name, | ||||
|         country: country?.name || "", | ||||
|         country_code: country_code, | ||||
|         arms, | ||||
|       }); | ||||
|  | ||||
|         runInAction(() => { | ||||
|           if (this.city[code]) { | ||||
|             this.city[code][language as keyof CashedCities] = { | ||||
|               name, | ||||
|               country: country?.name || "", | ||||
|               country_code: country_code, | ||||
|               arms, | ||||
|             }; | ||||
|           } | ||||
|       runInAction(() => { | ||||
|         if (this.city[code]) { | ||||
|           this.city[code][language as keyof CashedCities] = { | ||||
|             name, | ||||
|             country: country?.name || "", | ||||
|             country_code: country_code, | ||||
|             arms, | ||||
|           }; | ||||
|         } | ||||
|  | ||||
|           if (this.cities[language as keyof CashedCities]) { | ||||
|             this.cities[language as keyof CashedCities] = this.cities[ | ||||
|               language as keyof CashedCities | ||||
|             ].map((city) => | ||||
|               city.id === Number(code) | ||||
|                 ? { | ||||
|                     id: city.id, | ||||
|                     name, | ||||
|                     country: country?.name || "", | ||||
|                     country_code: country_code, | ||||
|                     arms, | ||||
|                   } | ||||
|                 : city | ||||
|             ); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|         if (this.cities[language as keyof CashedCities]) { | ||||
|           this.cities[language as keyof CashedCities].data = this.cities[ | ||||
|             language as keyof CashedCities | ||||
|           ].data.map((city) => | ||||
|             city.id === Number(code) | ||||
|               ? { | ||||
|                   id: city.id, | ||||
|                   name, | ||||
|                   country: country?.name || "", | ||||
|                   country_code: country_code, | ||||
|                   arms, | ||||
|                 } | ||||
|               : city | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -12,9 +12,18 @@ export type Country = { | ||||
| }; | ||||
|  | ||||
| export type CashedCountries = { | ||||
|   ru: Country[]; | ||||
|   en: Country[]; | ||||
|   zh: Country[]; | ||||
|   ru: { | ||||
|     data: Country[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   en: { | ||||
|     data: Country[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
|   zh: { | ||||
|     data: Country[]; | ||||
|     loaded: boolean; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export type CashedCountry = { | ||||
| @@ -25,9 +34,18 @@ export type CashedCountry = { | ||||
|  | ||||
| class CountryStore { | ||||
|   countries: CashedCountries = { | ||||
|     ru: [], | ||||
|     en: [], | ||||
|     zh: [], | ||||
|     ru: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     en: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|     zh: { | ||||
|       data: [], | ||||
|       loaded: false, | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   country: Record<string, CashedCountry> = {}; | ||||
| @@ -37,26 +55,20 @@ class CountryStore { | ||||
|   } | ||||
|  | ||||
|   getCountries = async (language: keyof CashedCountries) => { | ||||
|     if (this.countries[language] && this.countries[language].length > 0) { | ||||
|     if (this.countries[language].loaded) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/country`); | ||||
|     const response = await languageInstance(language).get(`/country`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.countries[language] = response.data; | ||||
|       this.countries[language].data = response.data; | ||||
|       this.countries[language].loaded = true; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   getCountry = async (code: string, language: keyof CashedCountries) => { | ||||
|     if ( | ||||
|       this.country[code]?.[language] && | ||||
|       this.country[code][language] !== null | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/country/${code}`); | ||||
|     const response = await languageInstance(language).get(`/country/${code}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       if (!this.country[code]) { | ||||
| @@ -72,14 +84,21 @@ class CountryStore { | ||||
|     return response.data; | ||||
|   }; | ||||
|  | ||||
|   deleteCountry = async (code: string, language: keyof CashedCountries) => { | ||||
|   deleteCountry = async (code: string) => { | ||||
|     await authInstance.delete(`/country/${code}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.countries[language] = this.countries[language].filter( | ||||
|         (country) => country.code !== code | ||||
|       ); | ||||
|       this.country[code][language] = null; | ||||
|       for (const lang of ["ru", "en", "zh"]) { | ||||
|         this.countries[lang as keyof CashedCountries].data = this.countries[ | ||||
|           lang as keyof CashedCountries | ||||
|         ].data.filter((country) => country.code !== code); | ||||
|       } | ||||
|  | ||||
|       this.country[code] = { | ||||
|         ru: null, | ||||
|         en: null, | ||||
|         zh: null, | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -121,8 +140,8 @@ class CountryStore { | ||||
|       }); | ||||
|  | ||||
|       runInAction(() => { | ||||
|         this.countries[language as keyof CashedCountries] = [ | ||||
|           ...this.countries[language as keyof CashedCountries], | ||||
|         this.countries[language as keyof CashedCountries].data = [ | ||||
|           ...this.countries[language as keyof CashedCountries].data, | ||||
|           { code: code, name: name }, | ||||
|         ]; | ||||
|       }); | ||||
| @@ -142,8 +161,8 @@ class CountryStore { | ||||
|           ); | ||||
|         } | ||||
|         runInAction(() => { | ||||
|           this.countries[secondaryLanguage as keyof CashedCountries] = [ | ||||
|             ...this.countries[secondaryLanguage as keyof CashedCountries], | ||||
|           this.countries[secondaryLanguage as keyof CashedCountries].data = [ | ||||
|             ...this.countries[secondaryLanguage as keyof CashedCountries].data, | ||||
|             { code: code, name: name }, | ||||
|           ]; | ||||
|         }); | ||||
| @@ -204,11 +223,10 @@ class CountryStore { | ||||
|             }; | ||||
|           } | ||||
|           if (this.countries[language as keyof CashedCountries]) { | ||||
|             this.countries[language as keyof CashedCountries] = this.countries[ | ||||
|               language as keyof CashedCountries | ||||
|             ].map((country) => | ||||
|               country.code === code ? { code, name } : country | ||||
|             ); | ||||
|             this.countries[language as keyof CashedCountries].data = | ||||
|               this.countries[language as keyof CashedCountries].data.map( | ||||
|                 (country) => (country.code === code ? { code, name } : country) | ||||
|               ); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|   | ||||
| @@ -1,5 +1,11 @@ | ||||
| // @shared/stores/createSightStore.ts | ||||
| import { Language, authInstance, languageInstance, mediaStore } from "@shared"; | ||||
| import { | ||||
|   articlesStore, | ||||
|   Language, | ||||
|   authInstance, | ||||
|   languageInstance, | ||||
|   mediaStore, | ||||
| } from "@shared"; | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| type MediaItem = { | ||||
| @@ -162,6 +168,8 @@ class CreateSightStore { | ||||
|           media: mediaData, | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       return articleId; // Return the linked article ID | ||||
|     } catch (error) { | ||||
|       console.error("Error linking existing right article:", error); | ||||
|       throw error; | ||||
| @@ -315,7 +323,18 @@ class CreateSightStore { | ||||
|   deleteLeftArticle = async (articleId: number) => { | ||||
|     /* ... your existing logic ... */ | ||||
|     await authInstance.delete(`/article/${articleId}`); | ||||
|     // articlesStore.getArticles(languageStore.language); // If still needed | ||||
|     // articlesStore.getArticles(languageStore.language); // If still neede | ||||
|     runInAction(() => { | ||||
|       articlesStore.articles.ru = articlesStore.articles.ru.filter( | ||||
|         (article) => article.id !== articleId | ||||
|       ); | ||||
|       articlesStore.articles.en = articlesStore.articles.en.filter( | ||||
|         (article) => article.id !== articleId | ||||
|       ); | ||||
|       articlesStore.articles.zh = articlesStore.articles.zh.filter( | ||||
|         (article) => article.id !== articleId | ||||
|       ); | ||||
|     }); | ||||
|     this.unlinkLeftArticle(); | ||||
|   }; | ||||
|  | ||||
| @@ -352,6 +371,25 @@ class CreateSightStore { | ||||
|         body: "填写内容", | ||||
|         media: [], | ||||
|       }; | ||||
|  | ||||
|       articlesStore.articles.ru.push({ | ||||
|         id: newLeftArticleId, | ||||
|         heading: "Новая левая статья", | ||||
|         body: "Заполните контентом", | ||||
|         service_name: "Новая левая статья", | ||||
|       }); | ||||
|       articlesStore.articles.en.push({ | ||||
|         id: newLeftArticleId, | ||||
|         heading: "New Left Article", | ||||
|         body: "Fill with content", | ||||
|         service_name: "New Left Article", | ||||
|       }); | ||||
|       articlesStore.articles.zh.push({ | ||||
|         id: newLeftArticleId, | ||||
|         heading: "新的左侧文章", | ||||
|         body: "填写内容", | ||||
|         service_name: "新的左侧文章", | ||||
|       }); | ||||
|     }); | ||||
|     return newLeftArticleId; | ||||
|   }; | ||||
| @@ -513,8 +551,8 @@ class CreateSightStore { | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     console.log("Sight created with ID:", newSightId); | ||||
|     // Optionally: this.clearCreateSight(); // To reset form after successful creation | ||||
|     this.needLeaveAgree = false; | ||||
|     return newSightId; | ||||
|   }; | ||||
|  | ||||
|   | ||||
| @@ -12,6 +12,7 @@ class DevicesStore { | ||||
|  | ||||
|   getDevices = async () => { | ||||
|     const response = await authInstance.get(`${API_URL}/devices/connected`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.devices = response.data; | ||||
|     }); | ||||
|   | ||||
| @@ -1,5 +1,11 @@ | ||||
| // @shared/stores/editSightStore.ts | ||||
| import { authInstance, Language, languageInstance, mediaStore } from "@shared"; | ||||
| import { | ||||
|   articlesStore, | ||||
|   authInstance, | ||||
|   Language, | ||||
|   languageInstance, | ||||
|   mediaStore, | ||||
| } from "@shared"; | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
|  | ||||
| export type SightLanguageInfo = { | ||||
| @@ -82,11 +88,7 @@ class EditSightStore { | ||||
|  | ||||
|   hasLoadedCommon = false; | ||||
|   getSightInfo = async (id: number, language: Language) => { | ||||
|     if (this.sight[language].id === id) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const response = await authInstance.get(`/sight/${id}`); | ||||
|     const response = await languageInstance(language).get(`/sight/${id}`); | ||||
|     const data = response.data; | ||||
|  | ||||
|     if (data.left_article != 0 && data.left_article != null) { | ||||
| @@ -346,6 +348,8 @@ class EditSightStore { | ||||
|     //     body: this.sight.zh.left.body, | ||||
|     //   } | ||||
|     // ); | ||||
|  | ||||
|     this.needLeaveAgree = false; | ||||
|   }; | ||||
|  | ||||
|   getLeftArticle = async (id: number) => { | ||||
| @@ -374,6 +378,18 @@ class EditSightStore { | ||||
|  | ||||
|   deleteLeftArticle = async (id: number) => { | ||||
|     await authInstance.delete(`/article/${id}`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       articlesStore.articles.ru = articlesStore.articles.ru.filter( | ||||
|         (article) => article.id !== id | ||||
|       ); | ||||
|       articlesStore.articles.en = articlesStore.articles.en.filter( | ||||
|         (article) => article.id !== id | ||||
|       ); | ||||
|       articlesStore.articles.zh = articlesStore.articles.zh.filter( | ||||
|         (article) => article.id !== id | ||||
|       ); | ||||
|     }); | ||||
|     this.sight.common.left_article = 0; | ||||
|     this.sight.ru.left.heading = ""; | ||||
|     this.sight.en.left.heading = ""; | ||||
| @@ -481,9 +497,7 @@ class EditSightStore { | ||||
|         media_name: media_name, | ||||
|         media_type: type, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       console.log(error); | ||||
|     } | ||||
|     } catch (error) {} | ||||
|   }; | ||||
|  | ||||
|   createLinkWithArticle = async (media: { | ||||
| @@ -554,6 +568,8 @@ class EditSightStore { | ||||
|         media: mediaIds.data, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return article_id; // Return the linked article ID | ||||
|   }; | ||||
|  | ||||
|   deleteRightArticleMedia = async (article_id: number, media_id: string) => { | ||||
| @@ -637,6 +653,29 @@ class EditSightStore { | ||||
|       body: articleZhData.body, | ||||
|       media: [], | ||||
|     }); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       articlesStore.articles.ru.push({ | ||||
|         id: id, | ||||
|         heading: articleRuData.heading, | ||||
|         body: articleRuData.body, | ||||
|         service_name: articleRuData.heading, | ||||
|       }); | ||||
|       articlesStore.articles.en.push({ | ||||
|         id: id, | ||||
|         heading: articleEnData.heading, | ||||
|         body: articleEnData.body, | ||||
|         service_name: articleEnData.heading, | ||||
|       }); | ||||
|       articlesStore.articles.zh.push({ | ||||
|         id: id, | ||||
|         heading: articleZhData.heading, | ||||
|         body: articleZhData.body, | ||||
|         service_name: articleZhData.heading, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return id; // Return the ID of the newly created article | ||||
|   }; | ||||
|  | ||||
|   createLinkWithRightArticle = async ( | ||||
|   | ||||
							
								
								
									
										15
									
								
								src/shared/store/MenuStore/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/shared/store/MenuStore/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { makeAutoObservable } from "mobx"; | ||||
|  | ||||
| class MenuStore { | ||||
|   isOpen: boolean = true; | ||||
|  | ||||
|   constructor() { | ||||
|     makeAutoObservable(this); | ||||
|   } | ||||
|  | ||||
|   setIsMenuOpen = (isOpen: boolean) => { | ||||
|     this.isOpen = isOpen; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export const menuStore = new MenuStore(); | ||||
							
								
								
									
										101
									
								
								src/shared/store/ModelLoadingStore/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/shared/store/ModelLoadingStore/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| import { makeAutoObservable } from "mobx"; | ||||
|  | ||||
| export interface ModelLoadingState { | ||||
|   isLoading: boolean; | ||||
|   progress: number; | ||||
|   modelId: string | null; | ||||
|   error?: string; | ||||
|   startTime?: number; | ||||
| } | ||||
|  | ||||
| class ModelLoadingStore { | ||||
|   private loadingStates: Map<string, ModelLoadingState> = new Map(); | ||||
|  | ||||
|   constructor() { | ||||
|     makeAutoObservable(this); | ||||
|   } | ||||
|  | ||||
|   // Начать отслеживание загрузки модели | ||||
|   startLoading(modelId: string) { | ||||
|     this.loadingStates.set(modelId, { | ||||
|       isLoading: true, | ||||
|       progress: 0, | ||||
|       modelId, | ||||
|       startTime: Date.now(), | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Обновить прогресс загрузки | ||||
|   updateProgress(modelId: string, progress: number) { | ||||
|     const state = this.loadingStates.get(modelId); | ||||
|     if (state) { | ||||
|       state.progress = Math.min(100, Math.max(0, progress)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Завершить загрузку модели | ||||
|   finishLoading(modelId: string) { | ||||
|     const state = this.loadingStates.get(modelId); | ||||
|     if (state) { | ||||
|       state.isLoading = false; | ||||
|       state.progress = 100; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Остановить загрузку (в случае ошибки) | ||||
|   stopLoading(modelId: string) { | ||||
|     this.loadingStates.delete(modelId); | ||||
|   } | ||||
|  | ||||
|   // Обработать ошибку загрузки | ||||
|   handleError(modelId: string, error?: string) { | ||||
|     const state = this.loadingStates.get(modelId); | ||||
|     if (state) { | ||||
|       state.isLoading = false; | ||||
|       state.error = error || "Ошибка загрузки модели"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Получить состояние загрузки для конкретной модели | ||||
|   getLoadingState(modelId: string): ModelLoadingState | undefined { | ||||
|     return this.loadingStates.get(modelId); | ||||
|   } | ||||
|  | ||||
|   // Проверить, загружается ли какая-либо модель | ||||
|   get isAnyModelLoading(): boolean { | ||||
|     return Array.from(this.loadingStates.values()).some( | ||||
|       (state) => state.isLoading | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Получить все загружающиеся модели | ||||
|   get loadingModels(): ModelLoadingState[] { | ||||
|     return Array.from(this.loadingStates.values()).filter( | ||||
|       (state) => state.isLoading | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Получить общий прогресс всех загружающихся моделей | ||||
|   get overallProgress(): number { | ||||
|     const loadingModels = this.loadingModels; | ||||
|     if (loadingModels.length === 0) return 100; | ||||
|  | ||||
|     const totalProgress = loadingModels.reduce( | ||||
|       (sum, model) => sum + model.progress, | ||||
|       0 | ||||
|     ); | ||||
|     return Math.round(totalProgress / loadingModels.length); | ||||
|   } | ||||
|  | ||||
|   // Проверить, заблокировано ли сохранение (есть ли загружающиеся модели) | ||||
|   get isSaveBlocked(): boolean { | ||||
|     return this.isAnyModelLoading; | ||||
|   } | ||||
|  | ||||
|   // Очистить все состояния загрузки | ||||
|   clearAll() { | ||||
|     this.loadingStates.clear(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const modelLoadingStore = new ModelLoadingStore(); | ||||
| @@ -66,22 +66,41 @@ class RouteStore { | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   routeStations: Record<string, any[]> = {}; | ||||
|  | ||||
|   getRoute = async (id: number) => { | ||||
|     if (this.route[id]) return this.route[id]; | ||||
|     const response = await authInstance.get(`/route/${id}`); | ||||
|     const stations = await authInstance.get(`/route/${id}/station`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.route[id] = response.data; | ||||
|       this.routeStations[id] = stations.data; | ||||
|     }); | ||||
|  | ||||
|     return response.data; | ||||
|   }; | ||||
|  | ||||
|   setRouteStations = (routeId: number, stationId: number, data: any) => { | ||||
|     this.routeStations[routeId] = this.routeStations[routeId]?.map((station) => | ||||
|       station.id === stationId ? { ...station, ...data } : station | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   saveRouteStations = async (routeId: number, stationId: number) => { | ||||
|     await authInstance.patch(`/route/${routeId}/station`, { | ||||
|       ...this.routeStations[routeId]?.find( | ||||
|         (station) => station.id === stationId | ||||
|       ), | ||||
|       station_id: stationId, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   editRouteData = { | ||||
|     carrier: "", | ||||
|     carrier_id: 0, | ||||
|     center_latitude: 0, | ||||
|     center_longitude: 0, | ||||
|     center_latitude: "", | ||||
|     center_longitude: "", | ||||
|     governor_appeal: 0, | ||||
|     id: 0, | ||||
|     path: [] as number[][], | ||||
| @@ -99,10 +118,11 @@ class RouteStore { | ||||
|   }; | ||||
|  | ||||
|   editRoute = async (id: number) => { | ||||
|     const response = await authInstance.patch( | ||||
|       `/route/${id}`, | ||||
|       this.editRouteData | ||||
|     ); | ||||
|     const response = await authInstance.patch(`/route/${id}`, { | ||||
|       ...this.editRouteData, | ||||
|       center_latitude: parseFloat(this.editRouteData.center_latitude), | ||||
|       center_longitude: parseFloat(this.editRouteData.center_longitude), | ||||
|     }); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.route[id] = response.data; | ||||
| @@ -111,6 +131,20 @@ class RouteStore { | ||||
|       ); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   copyRouteAction = async (id: number) => { | ||||
|     const response = await authInstance.post(`/route/${id}/copy`); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.routes.data = [...this.routes.data, response.data]; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   selectedStationId = 0; | ||||
|  | ||||
|   setSelectedStationId = (id: number) => { | ||||
|     this.selectedStationId = id; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export const routeStore = new RouteStore(); | ||||
|   | ||||
							
								
								
									
										48
									
								
								src/shared/store/SelectedCityStore/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/shared/store/SelectedCityStore/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
| import { City } from "../CityStore"; | ||||
|  | ||||
| class SelectedCityStore { | ||||
|   selectedCity: City | null = null; | ||||
|  | ||||
|   constructor() { | ||||
|     makeAutoObservable(this); | ||||
|     this.initialize(); | ||||
|   } | ||||
|  | ||||
|   private initialize() { | ||||
|     const storedCity = localStorage.getItem("selectedCity"); | ||||
|     if (storedCity) { | ||||
|       try { | ||||
|         this.selectedCity = JSON.parse(storedCity); | ||||
|       } catch (error) { | ||||
|         console.error("Error parsing stored city:", error); | ||||
|         localStorage.removeItem("selectedCity"); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   setSelectedCity = (city: City | null) => { | ||||
|     runInAction(() => { | ||||
|       this.selectedCity = city; | ||||
|       if (city) { | ||||
|         localStorage.setItem("selectedCity", JSON.stringify(city)); | ||||
|       } else { | ||||
|         localStorage.removeItem("selectedCity"); | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   clearSelectedCity = () => { | ||||
|     this.setSelectedCity(null); | ||||
|   }; | ||||
|  | ||||
|   get selectedCityId() { | ||||
|     return this.selectedCity?.id || null; | ||||
|   } | ||||
|  | ||||
|   get selectedCityName() { | ||||
|     return this.selectedCity?.name || null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const selectedCityStore = new SelectedCityStore(); | ||||
| @@ -97,15 +97,19 @@ class SightsStore { | ||||
|     city: number, | ||||
|     coordinates: { latitude: number; longitude: number } | ||||
|   ) => { | ||||
|     const id = ( | ||||
|       await authInstance.post("/sight", { | ||||
|         name: this.createSight[languageStore.language].name, | ||||
|         address: this.createSight[languageStore.language].address, | ||||
|         city_id: city, | ||||
|         latitude: coordinates.latitude, | ||||
|         longitude: coordinates.longitude, | ||||
|       }) | ||||
|     ).data.id; | ||||
|     const response = await authInstance.post("/sight", { | ||||
|       name: this.createSight[languageStore.language].name, | ||||
|       address: this.createSight[languageStore.language].address, | ||||
|       city_id: city, | ||||
|       latitude: coordinates.latitude, | ||||
|       longitude: coordinates.longitude, | ||||
|     }); | ||||
|  | ||||
|     runInAction(() => { | ||||
|       this.sights.push(response.data); | ||||
|     }); | ||||
|  | ||||
|     const id = response.data.id; | ||||
|  | ||||
|     const anotherLanguages = ["ru", "en", "zh"].filter( | ||||
|       (language) => language !== languageStore.language | ||||
|   | ||||
| @@ -1,6 +1,24 @@ | ||||
| import { authInstance } from "@shared"; | ||||
|  | ||||
| import { makeAutoObservable, runInAction } from "mobx"; | ||||
| // Импорт функции сброса кешей карты | ||||
| // import { clearMapCaches } from "../../pages/MapPage"; | ||||
| import { | ||||
|   articlesStore, | ||||
|   cityStore, | ||||
|   countryStore, | ||||
|   carrierStore, | ||||
|   stationsStore, | ||||
|   sightsStore, | ||||
|   routeStore, | ||||
|   vehicleStore, | ||||
|   userStore, | ||||
|   mediaStore, | ||||
|   createSightStore, | ||||
|   editSightStore, | ||||
|   devicesStore, | ||||
|   authStore, | ||||
| } from "@shared"; | ||||
|  | ||||
| type Snapshot = { | ||||
|   ID: string; | ||||
| @@ -17,6 +35,230 @@ class SnapshotStore { | ||||
|     makeAutoObservable(this); | ||||
|   } | ||||
|  | ||||
|   // Функция для сброса всех кешей в приложении | ||||
|   private clearAllCaches = () => { | ||||
|     // Сброс кешей статей | ||||
|     articlesStore.articleList = { | ||||
|       ru: { data: [], loaded: false }, | ||||
|       en: { data: [], loaded: false }, | ||||
|       zh: { data: [], loaded: false }, | ||||
|     }; | ||||
|     articlesStore.articlePreview = {}; | ||||
|     articlesStore.articleData = null; | ||||
|     articlesStore.articleMedia = null; | ||||
|  | ||||
|     // Сброс кешей городов | ||||
|     cityStore.cities = { | ||||
|       ru: { data: [], loaded: false }, | ||||
|       en: { data: [], loaded: false }, | ||||
|       zh: { data: [], loaded: false }, | ||||
|     }; | ||||
|     cityStore.ruCities = { data: [], loaded: false }; | ||||
|     cityStore.city = {}; | ||||
|  | ||||
|     // Сброс кешей стран | ||||
|     countryStore.countries = { | ||||
|       ru: { data: [], loaded: false }, | ||||
|       en: { data: [], loaded: false }, | ||||
|       zh: { data: [], loaded: false }, | ||||
|     }; | ||||
|  | ||||
|     // Сброс кешей перевозчиков | ||||
|     carrierStore.carriers = { | ||||
|       ru: { data: [], loaded: false }, | ||||
|       en: { data: [], loaded: false }, | ||||
|       zh: { data: [], loaded: false }, | ||||
|     }; | ||||
|  | ||||
|     // Сброс кешей станций | ||||
|     stationsStore.stationLists = { | ||||
|       ru: { data: [], loaded: false }, | ||||
|       en: { data: [], loaded: false }, | ||||
|       zh: { data: [], loaded: false }, | ||||
|     }; | ||||
|     stationsStore.stationPreview = {}; | ||||
|  | ||||
|     // Сброс кешей достопримечательностей | ||||
|     sightsStore.sights = []; | ||||
|     sightsStore.sight = null; | ||||
|  | ||||
|     // Сброс кешей маршрутов | ||||
|     routeStore.routes = { data: [], loaded: false }; | ||||
|  | ||||
|     // Сброс кешей транспорта | ||||
|     vehicleStore.vehicles = { data: [], loaded: false }; | ||||
|  | ||||
|     // Сброс кешей пользователей | ||||
|     userStore.users = { data: [], loaded: false }; | ||||
|  | ||||
|     // Сброс кешей медиа | ||||
|     mediaStore.media = []; | ||||
|     mediaStore.oneMedia = null; | ||||
|  | ||||
|     // Сброс кешей создания и редактирования достопримечательностей | ||||
|     createSightStore.sight = JSON.parse( | ||||
|       JSON.stringify({ | ||||
|         city_id: 0, | ||||
|         city: "", | ||||
|         latitude: 0, | ||||
|         longitude: 0, | ||||
|         thumbnail: null, | ||||
|         watermark_lu: null, | ||||
|         watermark_rd: null, | ||||
|         left_article: 0, | ||||
|         preview_media: null, | ||||
|         video_preview: null, | ||||
|         ru: { | ||||
|           name: "", | ||||
|           address: "", | ||||
|           left: { heading: "", body: "", media: [] }, | ||||
|           right: [], | ||||
|         }, | ||||
|         en: { | ||||
|           name: "", | ||||
|           address: "", | ||||
|           left: { heading: "", body: "", media: [] }, | ||||
|           right: [], | ||||
|         }, | ||||
|         zh: { | ||||
|           name: "", | ||||
|           address: "", | ||||
|           left: { heading: "", body: "", media: [] }, | ||||
|           right: [], | ||||
|         }, | ||||
|       }) | ||||
|     ); | ||||
|     createSightStore.uploadMediaOpen = false; | ||||
|     createSightStore.fileToUpload = null; | ||||
|     createSightStore.needLeaveAgree = false; | ||||
|  | ||||
|     editSightStore.sight = { | ||||
|       common: { | ||||
|         id: 0, | ||||
|         city_id: 0, | ||||
|         city: "", | ||||
|         latitude: 0, | ||||
|         longitude: 0, | ||||
|         thumbnail: null, | ||||
|         watermark_lu: null, | ||||
|         watermark_rd: null, | ||||
|         left_article: 0, | ||||
|         preview_media: null, | ||||
|         video_preview: null, | ||||
|       }, | ||||
|       ru: { | ||||
|         id: 0, | ||||
|         name: "", | ||||
|         address: "", | ||||
|         left: { heading: "", body: "", media: [] }, | ||||
|         right: [], | ||||
|       }, | ||||
|       en: { | ||||
|         id: 0, | ||||
|         name: "", | ||||
|         address: "", | ||||
|         left: { heading: "", body: "", media: [] }, | ||||
|         right: [], | ||||
|       }, | ||||
|       zh: { | ||||
|         id: 0, | ||||
|         name: "", | ||||
|         address: "", | ||||
|         left: { heading: "", body: "", media: [] }, | ||||
|         right: [], | ||||
|       }, | ||||
|     }; | ||||
|     editSightStore.hasLoadedCommon = false; | ||||
|     editSightStore.uploadMediaOpen = false; | ||||
|     editSightStore.fileToUpload = null; | ||||
|     editSightStore.needLeaveAgree = false; | ||||
|  | ||||
|     // Сброс кешей устройств | ||||
|     devicesStore.devices = []; | ||||
|     devicesStore.uuid = null; | ||||
|     devicesStore.sendSnapshotModalOpen = false; | ||||
|  | ||||
|     // Сброс кешей авторизации (кроме токена) | ||||
|     authStore.payload = null; | ||||
|     authStore.error = null; | ||||
|     authStore.isLoading = false; | ||||
|  | ||||
|     // Сброс кешей карты (если они загружены) | ||||
|     try { | ||||
|       // Сбрасываем кеши mapStore если он доступен | ||||
|       if (typeof window !== "undefined" && (window as any).mapStore) { | ||||
|         (window as any).mapStore.routes = []; | ||||
|         (window as any).mapStore.stations = []; | ||||
|         (window as any).mapStore.sights = []; | ||||
|       } | ||||
|  | ||||
|       // Сбрасываем кеши MapService если он доступен | ||||
|       if (typeof window !== "undefined" && (window as any).mapServiceInstance) { | ||||
|         (window as any).mapServiceInstance.clearCaches(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.warn("Не удалось сбросить кеши карты:", error); | ||||
|     } | ||||
|  | ||||
|     // Сброс localStorage кешей (кроме токена авторизации) | ||||
|     const token = localStorage.getItem("token"); | ||||
|     const rememberedEmail = localStorage.getItem("rememberedEmail"); | ||||
|     const rememberedPassword = localStorage.getItem("rememberedPassword"); | ||||
|  | ||||
|     localStorage.clear(); | ||||
|     sessionStorage.clear(); | ||||
|  | ||||
|     // Восстанавливаем важные данные | ||||
|     if (token) localStorage.setItem("token", token); | ||||
|     if (rememberedEmail) | ||||
|       localStorage.setItem("rememberedEmail", rememberedEmail); | ||||
|     if (rememberedPassword) | ||||
|       localStorage.setItem("rememberedPassword", rememberedPassword); | ||||
|  | ||||
|     // Сброс кешей карты (если они есть) | ||||
|     const mapPositionKey = "mapPosition"; | ||||
|     const activeSectionKey = "mapActiveSection"; | ||||
|     if (localStorage.getItem(mapPositionKey)) { | ||||
|       localStorage.removeItem(mapPositionKey); | ||||
|     } | ||||
|     if (localStorage.getItem(activeSectionKey)) { | ||||
|       localStorage.removeItem(activeSectionKey); | ||||
|     } | ||||
|  | ||||
|     // Попытка очистить кеш браузера (если поддерживается) | ||||
|     if ("caches" in window) { | ||||
|       try { | ||||
|         caches.keys().then((cacheNames) => { | ||||
|           return Promise.all( | ||||
|             cacheNames.map((cacheName) => { | ||||
|               return caches.delete(cacheName); | ||||
|             }) | ||||
|           ); | ||||
|         }); | ||||
|       } catch (error) { | ||||
|         console.warn("Кеш браузера не поддерживается:", error); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Попытка очистить IndexedDB (если поддерживается) | ||||
|     if ("indexedDB" in window) { | ||||
|       try { | ||||
|         indexedDB.databases().then((databases) => { | ||||
|           return Promise.all( | ||||
|             databases.map((db) => { | ||||
|               if (db.name) { | ||||
|                 return indexedDB.deleteDatabase(db.name); | ||||
|               } | ||||
|               return Promise.resolve(); | ||||
|             }) | ||||
|           ); | ||||
|         }); | ||||
|       } catch (error) { | ||||
|         console.warn("IndexedDB не поддерживается:", error); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   getSnapshots = async () => { | ||||
|     const response = await authInstance.get(`/snapshots`); | ||||
|  | ||||
| @@ -42,6 +284,10 @@ class SnapshotStore { | ||||
|   }; | ||||
|  | ||||
|   restoreSnapshot = async (id: string) => { | ||||
|     // Сначала сбрасываем все кеши | ||||
|     this.clearAllCaches(); | ||||
|  | ||||
|     // Затем восстанавливаем снапшот | ||||
|     await authInstance.post(`/snapshots/${id}/restore`); | ||||
|   }; | ||||
|  | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user