Latest version (#12)
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				release-tag / release-image (push) Successful in 2m17s
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	release-tag / release-image (push) Successful in 2m17s
				
			Co-authored-by: itoshi <kkzemeow@gmail.com> Co-authored-by: Spynder <19329095+Spynder@users.noreply.github.com> Reviewed-on: #12 Co-authored-by: Alexander Lazarenko <kerblif@unprism.ru> Co-committed-by: Alexander Lazarenko <kerblif@unprism.ru>
This commit is contained in:
		
							
								
								
									
										2
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/" | ||||||
|  | VITE_KRBL_API = "https://wn.krbl.ru" | ||||||
							
								
								
									
										51
									
								
								.gitea/workflows/publish.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								.gitea/workflows/publish.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | name: release-tag | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   release-image: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     container: | ||||||
|  |       image: catthehacker/ubuntu:act-latest | ||||||
|  |     env: | ||||||
|  |       DOCKER_ORG: krbl | ||||||
|  |       DOCKER_LATEST: nightly | ||||||
|  |       RUNNER_TOOL_CACHE: /toolcache | ||||||
|  |       IMAGE_NAME: white-nights-admin-panel | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout | ||||||
|  |         uses: actions/checkout@v3 | ||||||
|  |  | ||||||
|  |       - name: Set up QEMU | ||||||
|  |         uses: docker/setup-qemu-action@v2 | ||||||
|  |  | ||||||
|  |       - name: Set up Docker BuildX | ||||||
|  |         uses: docker/setup-buildx-action@v2 | ||||||
|  |         with: | ||||||
|  |           config-inline: | | ||||||
|  |             [registry."gitea.unprism.ru"]                     | ||||||
|  |  | ||||||
|  |       - name: Login to DockerHub | ||||||
|  |         uses: docker/login-action@v2 | ||||||
|  |         with: | ||||||
|  |           registry: gitea.unprism.ru | ||||||
|  |           username: ${{ secrets.DOCKER_USERNAME }} | ||||||
|  |           password: ${{ secrets.DOCKER_PASSWORD }} | ||||||
|  |  | ||||||
|  |       - name: Get Meta | ||||||
|  |         id: meta | ||||||
|  |         run: | | ||||||
|  |           echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT | ||||||
|  |           echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT                           | ||||||
|  |  | ||||||
|  |       - name: Build and push | ||||||
|  |         uses: docker/build-push-action@v4 | ||||||
|  |         with: | ||||||
|  |           context: . | ||||||
|  |           file: ./Dockerfile | ||||||
|  |           platforms: | | ||||||
|  |             linux/amd64 | ||||||
|  |           push: true | ||||||
|  |           tags: | | ||||||
|  |             gitea.unprism.ru/${{ env.DOCKER_ORG }}/${{ env.IMAGE_NAME }}:${{ env.DOCKER_LATEST }}             | ||||||
							
								
								
									
										31
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,37 +1,44 @@ | |||||||
| # This Dockerfile uses `serve` npm package to serve the static files with node process. | # This Dockerfile uses `serve` npm package to serve the static files with node process. | ||||||
| # You can find the Dockerfile for nginx in the following link: | # You can find the Dockerfile for nginx in the following link: | ||||||
| # https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx | # https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx | ||||||
| FROM refinedev/node:18 AS base |  | ||||||
|  |  | ||||||
| FROM base as deps | FROM refinedev/node:20 AS base | ||||||
|  |  | ||||||
|  | FROM base AS deps | ||||||
|  |  | ||||||
|  | # Копируем только файлы зависимостей | ||||||
| COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ | ||||||
|  |  | ||||||
| RUN \ | RUN \ | ||||||
|   if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ |   if [ -f yarn.lock ]; then yarn install --frozen-lockfile; \ | ||||||
|   elif [ -f package-lock.json ]; then npm ci; \ |   elif [ -f package-lock.json ]; then npm ci; \ | ||||||
|   elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ |   elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile; \ | ||||||
|   else echo "Lockfile not found." && exit 1; \ |   else echo "❌ Lockfile not found." && exit 1; \ | ||||||
|   fi |   fi | ||||||
|  |  | ||||||
| FROM base as builder | FROM base AS builder | ||||||
|  |  | ||||||
| ENV NODE_ENV production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
|  | # Обязательно создать рабочую директорию | ||||||
|  | WORKDIR /app/refine | ||||||
|  |  | ||||||
| COPY --from=deps /app/refine/node_modules ./node_modules | COPY --from=deps /app/refine/node_modules ./node_modules | ||||||
|  |  | ||||||
| COPY . . | COPY . . | ||||||
|  |  | ||||||
| RUN npm run build | # Добавлена проверка и вывод логов | ||||||
|  | RUN echo "🚧 Starting build..." && npm run build || (echo "❌ Build failed" && exit 1) | ||||||
|  |  | ||||||
| FROM base as runner | FROM base AS runner | ||||||
|  |  | ||||||
| ENV NODE_ENV production | ENV NODE_ENV=production | ||||||
|  |  | ||||||
| RUN npm install -g serve | RUN npm install -g serve | ||||||
|  |  | ||||||
|  | WORKDIR /app/refine | ||||||
|  |  | ||||||
| COPY --from=builder /app/refine/dist ./ | COPY --from=builder /app/refine/dist ./ | ||||||
|  |  | ||||||
| USER refine | USER refine | ||||||
|  |  | ||||||
| CMD ["serve"] | CMD ["serve", "-s", ".", "-l", "3000"] | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								compose.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								compose.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | services: | ||||||
|  |   refine: | ||||||
|  |     image: white-nights:latest | ||||||
|  |     ports: | ||||||
|  |       - "3000:3000" | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| <html lang="en"> | <html lang="en"> | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="utf-8" /> |     <meta charset="utf-8" /> | ||||||
|     <link rel="icon" href="/favicon.ico" /> |     <link rel="icon" type="image/png" href="/favicon_ship.png" /> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> |     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||||
|     <meta name="theme-color" content="#000000" /> |     <meta name="theme-color" content="#000000" /> | ||||||
|     <meta |     <meta | ||||||
| @@ -19,9 +19,7 @@ | |||||||
|       name="twitter:image" |       name="twitter:image" | ||||||
|       content="https://refine.dev/img/refine_social.png" |       content="https://refine.dev/img/refine_social.png" | ||||||
|     /> |     /> | ||||||
|     <title> |     <title>Белые ночи</title> | ||||||
|       Белые ночи |  | ||||||
|     </title> |  | ||||||
|   </head> |   </head> | ||||||
|   <body> |   <body> | ||||||
|     <noscript>You need to enable JavaScript to run this app.</noscript> |     <noscript>You need to enable JavaScript to run this app.</noscript> | ||||||
|   | |||||||
							
								
								
									
										13255
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										13255
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										36
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								package.json
									
									
									
									
									
								
							| @@ -6,37 +6,63 @@ | |||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@emotion/react": "^11.8.2", |     "@emotion/react": "^11.8.2", | ||||||
|     "@emotion/styled": "^11.8.1", |     "@emotion/styled": "^11.8.1", | ||||||
|  |     "@hello-pangea/dnd": "^18.0.1", | ||||||
|     "@mui/icons-material": "^6.1.6", |     "@mui/icons-material": "^6.1.6", | ||||||
|     "@mui/lab": "^6.0.0-beta.14", |     "@mui/lab": "^6.0.0-beta.14", | ||||||
|     "@mui/material": "^6.1.7", |     "@mui/material": "^6.1.7", | ||||||
|     "@mui/x-data-grid": "^7.22.2", |     "@mui/x-data-grid": "^7.22.2", | ||||||
|  |     "@photo-sphere-viewer/core": "^5.13.2", | ||||||
|  |     "@pixi/react": "^8.0.0-beta.25", | ||||||
|  |     "@react-three/drei": "^10.0.6", | ||||||
|  |     "@react-three/fiber": "^9.1.2", | ||||||
|     "@refinedev/cli": "^2.16.21", |     "@refinedev/cli": "^2.16.21", | ||||||
|     "@refinedev/core": "^4.47.1", |     "@refinedev/core": "^4.57.9", | ||||||
|     "@refinedev/devtools": "^1.1.32", |     "@refinedev/devtools": "^1.1.32", | ||||||
|     "@refinedev/kbar": "^1.3.6", |     "@refinedev/kbar": "^1.3.16", | ||||||
|     "@refinedev/mui": "^6.0.0", |     "@refinedev/mui": "^6.0.0", | ||||||
|     "@refinedev/react-hook-form": "^4.8.14", |     "@refinedev/react-hook-form": "^4.8.14", | ||||||
|     "@refinedev/react-router": "^1.0.0", |     "@refinedev/react-router": "^1.0.0", | ||||||
|     "@refinedev/simple-rest": "^5.0.1", |     "@refinedev/simple-rest": "^5.0.1", | ||||||
|  |     "@tanstack/react-query": "^5.74.3", | ||||||
|  |     "@types/react-beautiful-dnd": "^13.1.8", | ||||||
|  |     "@types/react-simple-maps": "^3.0.6", | ||||||
|     "axios": "^1.7.9", |     "axios": "^1.7.9", | ||||||
|  |     "classnames": "^2.5.1", | ||||||
|  |     "d3-geo": "^3.1.1", | ||||||
|     "easymde": "^2.19.0", |     "easymde": "^2.19.0", | ||||||
|     "i18next": "^24.2.2", |     "i18next": "^24.2.2", | ||||||
|     "js-cookie": "^3.0.5", |     "js-cookie": "^3.0.5", | ||||||
|     "jwt-decode": "^4.0.0", |     "jwt-decode": "^4.0.0", | ||||||
|     "react": "^18.0.0", |     "lucide-react": "^0.511.0", | ||||||
|     "react-dom": "^18.0.0", |     "mobx": "^6.13.7", | ||||||
|  |     "mobx-react-lite": "^4.1.0", | ||||||
|  |     "pixi.js": "^8.2.6", | ||||||
|  |     "react": "19.0.0", | ||||||
|  |     "react-beautiful-dnd": "^13.1.1", | ||||||
|  |     "react-dom": "19.0.0", | ||||||
|  |     "react-draggable": "^4.4.6", | ||||||
|     "react-dropzone": "^14.3.8", |     "react-dropzone": "^14.3.8", | ||||||
|     "react-hook-form": "^7.30.0", |     "react-hook-form": "^7.30.0", | ||||||
|     "react-i18next": "^15.4.1", |     "react-i18next": "^15.4.1", | ||||||
|  |     "react-intl": "^7.1.10", | ||||||
|     "react-markdown": "^10.1.0", |     "react-markdown": "^10.1.0", | ||||||
|  |     "react-photo-sphere-viewer": "^6.2.3", | ||||||
|     "react-router": "^7.0.2", |     "react-router": "^7.0.2", | ||||||
|     "react-simplemde-editor": "^5.2.0" |     "react-simple-maps": "^3.0.0", | ||||||
|  |     "react-simplemde-editor": "^5.2.0", | ||||||
|  |     "react-swipeable": "^7.0.2", | ||||||
|  |     "react-toastify": "^11.0.5", | ||||||
|  |     "rehype-raw": "^7.0.0", | ||||||
|  |     "three": "^0.175.0", | ||||||
|  |     "vite-plugin-svgr": "^4.3.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|  |     "@types/d3-geo": "^3.1.0", | ||||||
|     "@types/js-cookie": "^3.0.6", |     "@types/js-cookie": "^3.0.6", | ||||||
|     "@types/node": "^18.16.2", |     "@types/node": "^18.16.2", | ||||||
|     "@types/react": "^18.0.0", |     "@types/react": "^18.0.0", | ||||||
|     "@types/react-dom": "^18.0.0", |     "@types/react-dom": "^18.0.0", | ||||||
|  |     "@types/three": "^0.175.0", | ||||||
|     "@typescript-eslint/eslint-plugin": "^5.57.1", |     "@typescript-eslint/eslint-plugin": "^5.57.1", | ||||||
|     "@typescript-eslint/parser": "^5.57.1", |     "@typescript-eslint/parser": "^5.57.1", | ||||||
|     "@vitejs/plugin-react": "^4.0.0", |     "@vitejs/plugin-react": "^4.0.0", | ||||||
|   | |||||||
							
								
								
									
										7245
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7245
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										373
									
								
								public/Emblem.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										373
									
								
								public/Emblem.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 176 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/GET.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/GET.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/SightIcon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/SightIcon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 750 B | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 7.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/favicon_ship.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/favicon_ship.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 2.3 KiB | 
							
								
								
									
										441
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										441
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -1,190 +1,272 @@ | |||||||
| import {Refine, Authenticated} from '@refinedev/core' | import { Refine, Authenticated } from "@refinedev/core"; | ||||||
| import {DevtoolsPanel, DevtoolsProvider} from '@refinedev/devtools' | import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools"; | ||||||
| import {RefineKbar, RefineKbarProvider} from '@refinedev/kbar' |  | ||||||
|  |  | ||||||
| import {ErrorComponent, useNotificationProvider, RefineSnackbarProvider, ThemedLayoutV2} from '@refinedev/mui' | import { | ||||||
|  |   ErrorComponent, | ||||||
|  |   useNotificationProvider, | ||||||
|  |   RefineSnackbarProvider, | ||||||
|  |   ThemedLayoutV2, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  |  | ||||||
| import {customDataProvider} from './providers/data' | import { customDataProvider } from "./providers/data"; | ||||||
| import CssBaseline from '@mui/material/CssBaseline' | import CssBaseline from "@mui/material/CssBaseline"; | ||||||
| import GlobalStyles from '@mui/material/GlobalStyles' | import GlobalStyles from "@mui/material/GlobalStyles"; | ||||||
| import {BrowserRouter, Route, Routes, Outlet} from 'react-router' | import { BrowserRouter, Route, Routes, Outlet } from "react-router"; | ||||||
| import routerBindings, {NavigateToResource, CatchAllNavigate, UnsavedChangesNotifier, DocumentTitleHandler} from '@refinedev/react-router' | import routerBindings, { | ||||||
| import {ColorModeContextProvider} from './contexts/color-mode' |   NavigateToResource, | ||||||
| import {Header} from './components/header' |   CatchAllNavigate, | ||||||
| import {Login} from './pages/login' |   UnsavedChangesNotifier, | ||||||
| import {authProvider} from './authProvider' |   DocumentTitleHandler, | ||||||
| import {i18nProvider} from './i18nProvider' | } from "@refinedev/react-router"; | ||||||
|  | import { ColorModeContextProvider } from "./contexts/color-mode"; | ||||||
|  | import { Header } from "./components/header"; | ||||||
|  | import { Login } from "./pages/login"; | ||||||
|  | import { authProvider, i18nProvider } from "@providers"; | ||||||
|  |  | ||||||
| import {CountryList, CountryCreate, CountryEdit, CountryShow} from './pages/country' | import { | ||||||
| import {CityList, CityCreate, CityEdit, CityShow} from './pages/city' |   CountryList, | ||||||
| import {CarrierList, CarrierCreate, CarrierEdit, CarrierShow} from './pages/carrier' |   CountryCreate, | ||||||
| import {MediaList, MediaCreate, MediaEdit, MediaShow} from './pages/media' |   CountryEdit, | ||||||
| import {ArticleList, ArticleCreate, ArticleEdit, ArticleShow} from './pages/article' |   CountryShow, | ||||||
| import {SightList, SightCreate, SightEdit, SightShow} from './pages/sight' | } from "./pages/country"; | ||||||
| import {StationList, StationCreate, StationEdit, StationShow} from './pages/station' | import { CityList, CityCreate, CityEdit, CityShow } from "./pages/city"; | ||||||
| import {VehicleList, VehicleCreate, VehicleEdit, VehicleShow} from './pages/vehicle' | import { | ||||||
| import {RouteList, RouteCreate, RouteEdit, RouteShow} from './pages/route' |   CarrierList, | ||||||
| import {UserList, UserCreate, UserEdit, UserShow} from './pages/user' |   CarrierCreate, | ||||||
|  |   CarrierEdit, | ||||||
|  |   CarrierShow, | ||||||
|  | } from "./pages/carrier"; | ||||||
|  | import { MediaList, MediaCreate, MediaEdit, MediaShow } from "./pages/media"; | ||||||
|  | import { | ||||||
|  |   ArticleList, | ||||||
|  |   ArticleCreate, | ||||||
|  |   ArticleEdit, | ||||||
|  |   ArticleShow, | ||||||
|  | } from "./pages/article"; | ||||||
|  | import { SightList, SightCreate, SightEdit, SightShow } from "./pages/sight"; | ||||||
|  | import { | ||||||
|  |   StationList, | ||||||
|  |   StationCreate, | ||||||
|  |   StationEdit, | ||||||
|  |   StationShow, | ||||||
|  | } from "./pages/station"; | ||||||
|  | import { | ||||||
|  |   VehicleList, | ||||||
|  |   VehicleCreate, | ||||||
|  |   VehicleEdit, | ||||||
|  |   VehicleShow, | ||||||
|  | } from "./pages/vehicle"; | ||||||
|  | import { RouteList, RouteCreate, RouteEdit, RouteShow } from "./pages/route"; | ||||||
|  | import { RoutePreview } from "./pages/route-preview"; | ||||||
|  | import { UserList, UserCreate, UserEdit, UserShow } from "./pages/user"; | ||||||
|  |  | ||||||
| import {CountryIcon, CityIcon, CarrierIcon, MediaIcon, ArticleIcon, SightIcon, StationIcon, VehicleIcon, RouteIcon, UsersIcon} from './components/ui/Icons' | import { | ||||||
| import SidebarTitle from './components/ui/SidebarTitle' |   CountryIcon, | ||||||
| import {AdminOnly} from './components/AdminOnly' |   CityIcon, | ||||||
|  |   CarrierIcon, | ||||||
|  |   MediaIcon, | ||||||
|  |   ArticleIcon, | ||||||
|  |   SightIcon, | ||||||
|  |   StationIcon, | ||||||
|  |   VehicleIcon, | ||||||
|  |   RouteIcon, | ||||||
|  |   UsersIcon, | ||||||
|  | } from "./components/ui/Icons"; | ||||||
|  | import SidebarTitle from "./components/ui/SidebarTitle"; | ||||||
|  | import { AdminOnly } from "./components/AdminOnly"; | ||||||
|  |  | ||||||
|  | //import { LoadingProvider } from "@mt/utils"; | ||||||
|  | import { KBarProvider, RefineKbar } from "@refinedev/kbar"; | ||||||
|  | import { GitBranch } from "lucide-react"; | ||||||
|  | import { SnapshotList, SnapshotCreate, SnapshotShow } from "./pages/snapshot"; | ||||||
|  |  | ||||||
| function App() { | function App() { | ||||||
|   return ( |   return ( | ||||||
|     <BrowserRouter> |     <BrowserRouter> | ||||||
|       <RefineKbarProvider> |       <ColorModeContextProvider> | ||||||
|         <ColorModeContextProvider> |         <CssBaseline /> | ||||||
|           <CssBaseline /> |         <GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} /> | ||||||
|           <GlobalStyles styles={{html: {WebkitFontSmoothing: 'auto'}}} /> |         <RefineSnackbarProvider> | ||||||
|           <RefineSnackbarProvider> |           <DevtoolsProvider> | ||||||
|             <DevtoolsProvider> |             <Refine | ||||||
|               <Refine |               dataProvider={customDataProvider} | ||||||
|                 dataProvider={customDataProvider} |               notificationProvider={useNotificationProvider} | ||||||
|                 notificationProvider={useNotificationProvider} |               routerProvider={routerBindings} | ||||||
|                 routerProvider={routerBindings} |               authProvider={authProvider} | ||||||
|                 authProvider={authProvider} |               i18nProvider={i18nProvider} | ||||||
|                 i18nProvider={i18nProvider} |               resources={[ | ||||||
|                 resources={[ |                 { | ||||||
|                   { |                   name: "country", | ||||||
|                     name: 'country', |                   list: "/country", | ||||||
|                     list: '/country', |                   create: "/country/create", | ||||||
|                     create: '/country/create', |                   edit: "/country/edit/:id", | ||||||
|                     edit: '/country/edit/:id', |                   show: "/country/show/:id", | ||||||
|                     show: '/country/show/:id', |                   meta: { | ||||||
|                     meta: { |                     canDelete: true, | ||||||
|                       canDelete: true, |                     label: "Страны", | ||||||
|                       label: 'Страны', |                     icon: <CountryIcon />, | ||||||
|                       icon: <CountryIcon />, |  | ||||||
|                     }, |  | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'city', |                 { | ||||||
|                     list: '/city', |                   name: "city", | ||||||
|                     create: '/city/create', |                   list: "/city", | ||||||
|                     edit: '/city/edit/:id', |                   create: "/city/create", | ||||||
|                     show: '/city/show/:id', |                   edit: "/city/edit/:id", | ||||||
|                     meta: { |                   show: "/city/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Города', |                     canDelete: true, | ||||||
|                       icon: <CityIcon />, |                     label: "Города", | ||||||
|                     }, |                     icon: <CityIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'carrier', |                 { | ||||||
|                     list: '/carrier', |                   name: "carrier", | ||||||
|                     create: '/carrier/create', |                   list: "/carrier", | ||||||
|                     edit: '/carrier/edit/:id', |                   create: "/carrier/create", | ||||||
|                     show: '/carrier/show/:id', |                   edit: "/carrier/edit/:id", | ||||||
|                     meta: { |                   show: "/carrier/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Перевозчики', |                     canDelete: true, | ||||||
|                       icon: <CarrierIcon />, |                     label: "Перевозчики", | ||||||
|                     }, |                     icon: <CarrierIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'media', |                 { | ||||||
|                     list: '/media', |                   name: "media", | ||||||
|                     create: '/media/create', |                   list: "/media", | ||||||
|                     edit: '/media/edit/:id', |                   create: "/media/create", | ||||||
|                     show: '/media/show/:id', |                   edit: "/media/edit/:id", | ||||||
|                     meta: { |                   show: "/media/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Медиа', |                     canDelete: true, | ||||||
|                       icon: <MediaIcon />, |                     label: "Медиа", | ||||||
|                     }, |                     icon: <MediaIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'article', |                 { | ||||||
|                     list: '/article', |                   name: "article", | ||||||
|                     create: '/article/create', |                   list: "/article", | ||||||
|                     edit: '/article/edit/:id', |                   create: "/article/create", | ||||||
|                     show: '/article/show/:id', |                   edit: "/article/edit/:id", | ||||||
|                     meta: { |                   show: "/article/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Статьи', |                     canDelete: true, | ||||||
|                       icon: <ArticleIcon />, |                     label: "Статьи", | ||||||
|                     }, |                     icon: <ArticleIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'sight', |                 { | ||||||
|                     list: '/sight', |                   name: "sight", | ||||||
|                     create: '/sight/create', |                   list: "/sight", | ||||||
|                     edit: '/sight/edit/:id', |                   create: "/sight/create", | ||||||
|                     show: '/sight/show/:id', |                   edit: "/sight/edit/:id", | ||||||
|                     meta: { |                   show: "/sight/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Достопримечательности', |                     canDelete: true, | ||||||
|                       icon: <SightIcon />, |                     label: "Достопримечательности", | ||||||
|                     }, |                     icon: <SightIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'station', |                 { | ||||||
|                     list: '/station', |                   name: "station", | ||||||
|                     create: '/station/create', |                   list: "/station", | ||||||
|                     edit: '/station/edit/:id', |                   create: "/station/create", | ||||||
|                     show: '/station/show/:id', |                   edit: "/station/edit/:id", | ||||||
|                     meta: { |                   show: "/station/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Остановки', |                     canDelete: true, | ||||||
|                       icon: <StationIcon />, |                     label: "Остановки", | ||||||
|                     }, |                     icon: <StationIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'vehicle', |                 { | ||||||
|                     list: '/vehicle', |                   name: "snapshots", | ||||||
|                     create: '/vehicle/create', |                   list: "/snapshot", | ||||||
|                     edit: '/vehicle/edit/:id', |                   create: "/snapshot/create", | ||||||
|                     show: '/vehicle/show/:id', |  | ||||||
|                     meta: { |                   show: "/snapshot/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Транспорт', |                     canDelete: true, | ||||||
|                       icon: <VehicleIcon />, |                     label: "Снапшоты", | ||||||
|                     }, |                     icon: <GitBranch />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'route', |  | ||||||
|                     list: '/route', |                 { | ||||||
|                     create: '/route/create', |                   name: "vehicle", | ||||||
|                     edit: '/route/edit/:id', |                   list: "/vehicle", | ||||||
|                     show: '/route/show/:id', |                   create: "/vehicle/create", | ||||||
|                     meta: { |                   edit: "/vehicle/edit/:id", | ||||||
|                       canDelete: true, |                   show: "/vehicle/show/:id", | ||||||
|                       label: 'Маршруты', |                   meta: { | ||||||
|                       icon: <RouteIcon />, |                     canDelete: true, | ||||||
|                     }, |                     label: "Транспорт", | ||||||
|  |                     icon: <VehicleIcon />, | ||||||
|                   }, |                   }, | ||||||
|                   { |                 }, | ||||||
|                     name: 'user', |                 { | ||||||
|                     list: '/user', |                   name: "route", | ||||||
|                     create: '/user/create', |                   list: "/route", | ||||||
|                     edit: '/user/edit/:id', |                   create: "/route/create", | ||||||
|                     show: '/user/show/:id', |                   edit: "/route/edit/:id", | ||||||
|                     meta: { |                   show: "/route/show/:id", | ||||||
|                       canDelete: true, |                   meta: { | ||||||
|                       label: 'Пользователи', |                     canDelete: true, | ||||||
|                       icon: <UsersIcon />, |                     label: "Маршруты", | ||||||
|                     }, |                     icon: <RouteIcon />, | ||||||
|                   }, |                   }, | ||||||
|                 ]} |                 }, | ||||||
|                 options={{ |                 { | ||||||
|                   syncWithLocation: true, |                   name: "route-preview", | ||||||
|                   warnWhenUnsavedChanges: true, // Включаем глобально |                   list: "/route", | ||||||
|                   useNewQueryKeys: true, |                   show: "/route/:id/station", | ||||||
|                   projectId: 'Wv044J-t53S3s-PcbJGe', |                   meta: { | ||||||
|                 }} |                     hide: true, | ||||||
|               > |                     stations: "route/:id/station", | ||||||
|  |                   }, | ||||||
|  |                 }, | ||||||
|  |                 { | ||||||
|  |                   name: "user", | ||||||
|  |                   list: "/user", | ||||||
|  |                   create: "/user/create", | ||||||
|  |                   edit: "/user/edit/:id", | ||||||
|  |                   show: "/user/show/:id", | ||||||
|  |                   meta: { | ||||||
|  |                     canDelete: true, | ||||||
|  |                     label: "Пользователи", | ||||||
|  |                     icon: <UsersIcon />, | ||||||
|  |                   }, | ||||||
|  |                 }, | ||||||
|  |               ]} | ||||||
|  |               options={{ | ||||||
|  |                 syncWithLocation: true, | ||||||
|  |                 warnWhenUnsavedChanges: true, // Включаем глобально | ||||||
|  |                 useNewQueryKeys: true, | ||||||
|  |                 projectId: "Wv044J-t53S3s-PcbJGe", | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               <KBarProvider> | ||||||
|                 <Routes> |                 <Routes> | ||||||
|  |                   <Route path="/route-preview"> | ||||||
|  |                     <Route path=":id" element={<RoutePreview />} /> | ||||||
|  |                   </Route> | ||||||
|  |  | ||||||
|                   <Route |                   <Route | ||||||
|                     element={ |                     element={ | ||||||
|                       <Authenticated key="authenticated-inner" fallback={<CatchAllNavigate to="/login" />}> |                       <Authenticated | ||||||
|  |                         key="authenticated-inner" | ||||||
|  |                         fallback={<CatchAllNavigate to="/login" />} | ||||||
|  |                       > | ||||||
|                         <ThemedLayoutV2 Header={Header} Title={SidebarTitle}> |                         <ThemedLayoutV2 Header={Header} Title={SidebarTitle}> | ||||||
|                           <Outlet /> |                           <Outlet /> | ||||||
|                         </ThemedLayoutV2> |                         </ThemedLayoutV2> | ||||||
|                       </Authenticated> |                       </Authenticated> | ||||||
|                     } |                     } | ||||||
|                   > |                   > | ||||||
|                     <Route index element={<NavigateToResource resource="country" />} /> |                     <Route | ||||||
|  |                       index | ||||||
|  |                       element={<NavigateToResource resource="country" />} | ||||||
|  |                     /> | ||||||
|  |  | ||||||
|                     <Route path="/country"> |                     <Route path="/country"> | ||||||
|                       <Route index element={<CountryList />} /> |                       <Route index element={<CountryList />} /> | ||||||
| @@ -207,6 +289,20 @@ function App() { | |||||||
|                       <Route path="show/:id" element={<CountryShow />} /> |                       <Route path="show/:id" element={<CountryShow />} /> | ||||||
|                     </Route> |                     </Route> | ||||||
|  |  | ||||||
|  |                     <Route path="/snapshot"> | ||||||
|  |                       <Route index element={<SnapshotList />} /> | ||||||
|  |                       <Route | ||||||
|  |                         path="create" | ||||||
|  |                         element={ | ||||||
|  |                           <AdminOnly> | ||||||
|  |                             <SnapshotCreate /> | ||||||
|  |                           </AdminOnly> | ||||||
|  |                         } | ||||||
|  |                       /> | ||||||
|  |  | ||||||
|  |                       <Route path="show/:id" element={<SnapshotShow />} /> | ||||||
|  |                     </Route> | ||||||
|  |  | ||||||
|                     <Route path="/city"> |                     <Route path="/city"> | ||||||
|                       <Route index element={<CityList />} /> |                       <Route index element={<CityList />} /> | ||||||
|                       <Route |                       <Route | ||||||
| @@ -344,7 +440,10 @@ function App() { | |||||||
|                   </Route> |                   </Route> | ||||||
|                   <Route |                   <Route | ||||||
|                     element={ |                     element={ | ||||||
|                       <Authenticated key="authenticated-outer" fallback={<Outlet />}> |                       <Authenticated | ||||||
|  |                         key="authenticated-outer" | ||||||
|  |                         fallback={<Outlet />} | ||||||
|  |                       > | ||||||
|                         <NavigateToResource /> |                         <NavigateToResource /> | ||||||
|                       </Authenticated> |                       </Authenticated> | ||||||
|                     } |                     } | ||||||
| @@ -353,23 +452,23 @@ function App() { | |||||||
|                   </Route> |                   </Route> | ||||||
|                 </Routes> |                 </Routes> | ||||||
|  |  | ||||||
|                 <RefineKbar /> |  | ||||||
|                 <UnsavedChangesNotifier /> |                 <UnsavedChangesNotifier /> | ||||||
|                 <DocumentTitleHandler |                 <DocumentTitleHandler | ||||||
|                   handler={() => { |                   handler={() => { | ||||||
|                     // const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim() |                     // const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim() | ||||||
|                     // return `${cleanedTitle} — Белые ночи` |                     // return `${cleanedTitle} — Белые ночи` | ||||||
|                     return 'Белые ночи' |                     return "Белые ночи"; | ||||||
|                   }} |                   }} | ||||||
|                 /> |                 /> | ||||||
|               </Refine> |                 <RefineKbar /> | ||||||
|               <DevtoolsPanel /> |               </KBarProvider> | ||||||
|             </DevtoolsProvider> |             </Refine> | ||||||
|           </RefineSnackbarProvider> |             <DevtoolsPanel /> | ||||||
|         </ColorModeContextProvider> |           </DevtoolsProvider> | ||||||
|       </RefineKbarProvider> |         </RefineSnackbarProvider> | ||||||
|  |       </ColorModeContextProvider> | ||||||
|     </BrowserRouter> |     </BrowserRouter> | ||||||
|   ) |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| export default App | export default App; | ||||||
|   | |||||||
| @@ -1,174 +0,0 @@ | |||||||
| import type {AuthProvider} from '@refinedev/core' |  | ||||||
| import axios, {AxiosError} from 'axios' |  | ||||||
| import {BACKEND_URL} from './lib/constants' |  | ||||||
| import {jwtDecode} from 'jwt-decode' |  | ||||||
|  |  | ||||||
| export const TOKEN_KEY = 'refine-auth' |  | ||||||
|  |  | ||||||
| interface AuthResponse { |  | ||||||
|   token: string |  | ||||||
|   user: { |  | ||||||
|     id: number |  | ||||||
|     name: string |  | ||||||
|     email: string |  | ||||||
|     is_admin: boolean |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| interface ErrorResponse { |  | ||||||
|   message: string |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class AuthError extends Error { |  | ||||||
|   constructor(message: string) { |  | ||||||
|     super(message) |  | ||||||
|     this.name = 'AuthError' |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| interface JWTPayload { |  | ||||||
|   user_id: number |  | ||||||
|   email: string |  | ||||||
|   is_admin: boolean |  | ||||||
|   exp: number |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const authProvider: AuthProvider = { |  | ||||||
|   login: async ({email, password}) => { |  | ||||||
|     try { |  | ||||||
|       const response = await axios.post<AuthResponse>(`${BACKEND_URL}/auth/login`, { |  | ||||||
|         email, |  | ||||||
|         password, |  | ||||||
|       }) |  | ||||||
|  |  | ||||||
|       const {token, user} = response.data |  | ||||||
|  |  | ||||||
|       if (token) { |  | ||||||
|         localStorage.setItem(TOKEN_KEY, token) |  | ||||||
|         localStorage.setItem('user', JSON.stringify(user)) |  | ||||||
|  |  | ||||||
|         return { |  | ||||||
|           success: true, |  | ||||||
|           redirectTo: '/', |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       throw new AuthError('Неверный email или пароль') |  | ||||||
|     } catch (error) { |  | ||||||
|       const errorMessage = (error as AxiosError<ErrorResponse>)?.response?.data?.message || 'Неверный email или пароль' |  | ||||||
|  |  | ||||||
|       return { |  | ||||||
|         success: false, |  | ||||||
|         error: new AuthError(errorMessage), |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   logout: async () => { |  | ||||||
|     try { |  | ||||||
|       await axios.post( |  | ||||||
|         `${BACKEND_URL}/auth/logout`, |  | ||||||
|         {}, |  | ||||||
|         { |  | ||||||
|           headers: { |  | ||||||
|             Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, |  | ||||||
|           }, |  | ||||||
|         }, |  | ||||||
|       ) |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error('Ошибка при выходе:', error) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     localStorage.removeItem(TOKEN_KEY) |  | ||||||
|     localStorage.removeItem('user') |  | ||||||
|     return { |  | ||||||
|       success: true, |  | ||||||
|       redirectTo: '/login', |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   check: async () => { |  | ||||||
|     const token = localStorage.getItem(TOKEN_KEY) |  | ||||||
|     if (!token) { |  | ||||||
|       return { |  | ||||||
|         authenticated: false, |  | ||||||
|         redirectTo: '/login', |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       const response = await axios.get(`${BACKEND_URL}/auth/me`, { |  | ||||||
|         headers: { |  | ||||||
|           Authorization: `Bearer ${token}`, |  | ||||||
|         }, |  | ||||||
|       }) |  | ||||||
|  |  | ||||||
|       if (response.status === 200) { |  | ||||||
|         // Обновляем информацию о пользователе |  | ||||||
|         localStorage.setItem('user', JSON.stringify(response.data)) |  | ||||||
|         return { |  | ||||||
|           authenticated: true, |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } catch (error) { |  | ||||||
|       localStorage.removeItem(TOKEN_KEY) |  | ||||||
|       localStorage.removeItem('user') |  | ||||||
|       return { |  | ||||||
|         authenticated: false, |  | ||||||
|         redirectTo: '/login', |  | ||||||
|         error: new AuthError('Сессия истекла, пожалуйста, войдите снова'), |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       authenticated: false, |  | ||||||
|       redirectTo: '/login', |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   getPermissions: async () => { |  | ||||||
|     const token = localStorage.getItem(TOKEN_KEY) |  | ||||||
|     if (!token) return null |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       const decoded = jwtDecode<JWTPayload>(token) |  | ||||||
|       if (decoded.is_admin) { |  | ||||||
|         document.body.classList.add('is-admin') |  | ||||||
|       } else { |  | ||||||
|         document.body.classList.remove('is-admin') |  | ||||||
|       } |  | ||||||
|       return decoded.is_admin ? ['admin'] : ['user'] |  | ||||||
|     } catch { |  | ||||||
|       document.body.classList.remove('is-admin') |  | ||||||
|       return ['user'] |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   getIdentity: async () => { |  | ||||||
|     const token = localStorage.getItem(TOKEN_KEY) |  | ||||||
|     const user = localStorage.getItem('user') |  | ||||||
|  |  | ||||||
|     if (!token || !user) return null |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       const decoded = jwtDecode<JWTPayload>(token) |  | ||||||
|       const userData = JSON.parse(user) |  | ||||||
|  |  | ||||||
|       return { |  | ||||||
|         ...userData, |  | ||||||
|         is_admin: decoded.is_admin, // всегда используем значение из токена |  | ||||||
|       } |  | ||||||
|     } catch { |  | ||||||
|       return null |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   onError: async (error) => { |  | ||||||
|     const status = (error as AxiosError)?.response?.status |  | ||||||
|     if (status === 401 || status === 403) { |  | ||||||
|       localStorage.removeItem(TOKEN_KEY) |  | ||||||
|       localStorage.removeItem('user') |  | ||||||
|       return { |  | ||||||
|         logout: true, |  | ||||||
|         redirectTo: '/login', |  | ||||||
|         error: new AuthError('Сессия истекла, пожалуйста, войдите снова'), |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     return {error} |  | ||||||
|   }, |  | ||||||
| } |  | ||||||
| @@ -1,258 +1,397 @@ | |||||||
| import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material' | import { | ||||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore' |   Typography, | ||||||
| import {axiosInstance} from '../providers/data' |   Button, | ||||||
| import {BACKEND_URL} from '../lib/constants' |   Box, | ||||||
| import {useForm, Controller} from 'react-hook-form' |   Accordion, | ||||||
| import {MarkdownEditor} from './MarkdownEditor' |   AccordionSummary, | ||||||
| import React, {useState, useCallback} from 'react' |   AccordionDetails, | ||||||
| import {useDropzone} from 'react-dropzone' |   useTheme, | ||||||
| import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES} from '../components/media/MediaFormUtils' |   TextField, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||||
|  | import { axiosInstance } from "../providers/data"; | ||||||
|  | import { useForm, Controller } from "react-hook-form"; | ||||||
|  | import { MarkdownEditor } from "./MarkdownEditor"; | ||||||
|  | import React, { useState, useCallback, useEffect } from "react"; | ||||||
|  | import { useDropzone } from "react-dropzone"; | ||||||
|  | import { | ||||||
|  |   ALLOWED_IMAGE_TYPES, | ||||||
|  |   ALLOWED_VIDEO_TYPES, | ||||||
|  | } from "../components/media/MediaFormUtils"; | ||||||
|  | import { EVERY_LANGUAGE, Languages } from "@stores"; | ||||||
|  | import { useNotification } from "@refinedev/core"; | ||||||
|  |  | ||||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor) | const MemoizedSimpleMDE = React.memo(MarkdownEditor); | ||||||
|  |  | ||||||
| type MediaFile = { | type MediaFile = { | ||||||
|   file: File |   file: File; | ||||||
|   preview: string |   preview: string; | ||||||
|   uploading: boolean |   uploading: boolean; | ||||||
|   mediaId?: number |   mediaId?: number; | ||||||
| } | }; | ||||||
|  |  | ||||||
| type Props = { | type Props = { | ||||||
|   parentId: string | number |   parentId?: string | number; | ||||||
|   parentResource: string |   parentResource: string; | ||||||
|   childResource: string |   childResource: string; | ||||||
|   title: string |   title: string; | ||||||
| } |   left?: boolean; | ||||||
|  |   language: Languages; | ||||||
|  |   setHeadingParent?: (heading: string) => void; | ||||||
|  |   setBodyParent?: (body: string) => void; | ||||||
|  |   onSave?: (something: any) => void; | ||||||
|  |   noReset?: boolean; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => { | export const CreateSightArticle = ({ | ||||||
|   const theme = useTheme() |   parentId, | ||||||
|   const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]) |   parentResource, | ||||||
|  |   childResource, | ||||||
|  |   title, | ||||||
|  |   left, | ||||||
|  |   language, | ||||||
|  |   setHeadingParent, | ||||||
|  |   setBodyParent, | ||||||
|  |   onSave, | ||||||
|  |   noReset, | ||||||
|  | }: Props) => { | ||||||
|  |   const notification = useNotification(); | ||||||
|  |   const theme = useTheme(); | ||||||
|  |   const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]); | ||||||
|  |   const [workingLanguage, setWorkingLanguage] = useState<Languages>(language); | ||||||
|  |  | ||||||
|   const { |   const { | ||||||
|     register: registerItem, |     register: registerItem, | ||||||
|  |     watch, | ||||||
|     control: controlItem, |     control: controlItem, | ||||||
|     handleSubmit: handleSubmitItem, |     handleSubmit: handleSubmitItem, | ||||||
|     reset: resetItem, |     reset: resetItem, | ||||||
|     formState: {errors: itemErrors}, |     setValue, | ||||||
|  |     formState: { errors: itemErrors }, | ||||||
|   } = useForm({ |   } = useForm({ | ||||||
|     defaultValues: { |     defaultValues: { | ||||||
|       heading: '', |       heading: "", | ||||||
|       body: '', |       body: "", | ||||||
|     }, |     }, | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|  |   const [articleData, setArticleData] = useState({ | ||||||
|  |     heading: EVERY_LANGUAGE(""), | ||||||
|  |     body: EVERY_LANGUAGE(""), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   function updateTranslations() { | ||||||
|  |     const newArticleData = { | ||||||
|  |       ...articleData, | ||||||
|  |       heading: { | ||||||
|  |         ...articleData.heading, | ||||||
|  |         [workingLanguage]: watch("heading") ?? "", | ||||||
|  |       }, | ||||||
|  |       body: { | ||||||
|  |         ...articleData.body, | ||||||
|  |         [workingLanguage]: watch("body") ?? "", | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |     setArticleData(newArticleData); | ||||||
|  |     return newArticleData; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setValue("heading", articleData.heading[workingLanguage] ?? ""); | ||||||
|  |     setValue("body", articleData.body[workingLanguage] ?? ""); | ||||||
|  |   }, [workingLanguage, articleData, setValue]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     updateTranslations(); | ||||||
|  |     setWorkingLanguage(language); | ||||||
|  |   }, [language]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setHeadingParent?.(watch("heading")); | ||||||
|  |     setBodyParent?.(watch("body")); | ||||||
|  |   }, [watch("heading"), watch("body"), setHeadingParent, setBodyParent]); | ||||||
|  |  | ||||||
|   const simpleMDEOptions = React.useMemo( |   const simpleMDEOptions = React.useMemo( | ||||||
|     () => ({ |     () => ({ | ||||||
|       placeholder: 'Введите контент в формате Markdown...', |       placeholder: "Введите контент в формате Markdown...", | ||||||
|       spellChecker: false, |       spellChecker: false, | ||||||
|     }), |     }), | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   const onDrop = useCallback((acceptedFiles: File[]) => { |   const onDrop = useCallback((acceptedFiles: File[]) => { | ||||||
|     const newFiles = acceptedFiles.map((file) => ({ |     const newFiles = acceptedFiles.map((file) => ({ | ||||||
|       file, |       file, | ||||||
|       preview: URL.createObjectURL(file), |       preview: URL.createObjectURL(file), | ||||||
|       uploading: false, |       uploading: false, | ||||||
|     })) |     })); | ||||||
|     setMediaFiles((prev) => [...prev, ...newFiles]) |     setMediaFiles((prev) => [...prev, ...newFiles]); | ||||||
|   }, []) |   }, []); | ||||||
|  |  | ||||||
|   const {getRootProps, getInputProps, isDragActive} = useDropzone({ |   const { getRootProps, getInputProps, isDragActive } = useDropzone({ | ||||||
|     onDrop, |     onDrop, | ||||||
|     accept: { |     accept: { | ||||||
|       'image/*': ALLOWED_IMAGE_TYPES, |       "image/jpeg": [".jpeg", ".jpg"], | ||||||
|       'video/*': ALLOWED_VIDEO_TYPES, |       "image/png": [".png"], | ||||||
|  |       "image/webp": [".webp"], | ||||||
|  |       "video/mp4": [".mp4"], | ||||||
|  |       "video/webm": [".webm"], | ||||||
|  |       "video/ogg": [".ogg"], | ||||||
|     }, |     }, | ||||||
|     multiple: true, |     multiple: true, | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   const uploadMedia = async (mediaFile: MediaFile) => { |   const uploadMedia = async (mediaFile: MediaFile) => { | ||||||
|     const formData = new FormData() |     const formData = new FormData(); | ||||||
|     formData.append('media_name', mediaFile.file.name) |     formData.append("media_name", mediaFile.file.name); | ||||||
|     formData.append('filename', mediaFile.file.name) |     formData.append("filename", mediaFile.file.name); | ||||||
|     formData.append('type', mediaFile.file.type.startsWith('image/') ? '1' : '2') |     formData.append( | ||||||
|     formData.append('file', mediaFile.file) |       "type", | ||||||
|  |       mediaFile.file.type.startsWith("image/") ? "1" : "2" | ||||||
|  |     ); | ||||||
|  |     formData.append("file", mediaFile.file); | ||||||
|  |  | ||||||
|     const response = await axiosInstance.post(`${BACKEND_URL}/media`, formData) |     const response = await axiosInstance.post( | ||||||
|     return response.data.id |       `${import.meta.env.VITE_KRBL_API}/media`, | ||||||
|   } |       formData | ||||||
|  |     ); | ||||||
|  |     return response.data.id; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const handleCreate = async (data: {heading: string; body: string}) => { |   const handleCreate = async (data: { heading: string; body: string }) => { | ||||||
|     try { |     try { | ||||||
|       // Создаем статью |       // Создаем статью | ||||||
|       const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data) |       const response = await axiosInstance.post( | ||||||
|       const itemId = response.data.id |         `${import.meta.env.VITE_KRBL_API}/${childResource}`, | ||||||
|  |         { | ||||||
|  |           ...data, | ||||||
|  |           translations: updateTranslations(), | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |       const itemId = response.data.id; | ||||||
|  |  | ||||||
|       // Получаем существующие статьи для определения порядкового номера |       if (parentId) { | ||||||
|       const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) |         // Получаем существующие статьи для определения порядкового номера | ||||||
|       const existingItems = existingItemsResponse.data || [] |         const existingItemsResponse = await axiosInstance.get( | ||||||
|       const nextPageNum = existingItems.length + 1 |           `${ | ||||||
|  |             import.meta.env.VITE_KRBL_API | ||||||
|  |           }/${parentResource}/${parentId}/${childResource}` | ||||||
|  |         ); | ||||||
|  |         const existingItems = existingItemsResponse.data ?? []; | ||||||
|  |         const nextPageNum = existingItems.length + 1; | ||||||
|  |  | ||||||
|       // Привязываем статью к достопримечательности |         if (!left) { | ||||||
|       await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, { |           await axiosInstance.post( | ||||||
|         [`${childResource}_id`]: itemId, |             `${ | ||||||
|         page_num: nextPageNum, |               import.meta.env.VITE_KRBL_API | ||||||
|       }) |             }/${parentResource}/${parentId}/${childResource}/`, | ||||||
|  |             { | ||||||
|  |               [`${childResource}_id`]: itemId, | ||||||
|  |               page_num: nextPageNum, | ||||||
|  |             } | ||||||
|  |           ); | ||||||
|  |         } else { | ||||||
|  |           const response = await axiosInstance.get( | ||||||
|  |             `${import.meta.env.VITE_KRBL_API}/${parentResource}/${parentId}/` | ||||||
|  |           ); | ||||||
|  |           const data = response.data; | ||||||
|  |           if (data) { | ||||||
|  |             await axiosInstance.patch( | ||||||
|  |               `${import.meta.env.VITE_KRBL_API}/${parentResource}/${parentId}/`, | ||||||
|  |               { | ||||||
|  |                 ...data, | ||||||
|  |                 left_article: itemId, | ||||||
|  |               } | ||||||
|  |             ); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|       // Загружаем все медиа файлы и получаем их ID |       // Загружаем все медиа файлы и получаем их ID | ||||||
|       const mediaIds = await Promise.all( |       const mediaIds = await Promise.all( | ||||||
|         mediaFiles.map(async (mediaFile) => { |         mediaFiles.map(async (mediaFile) => { | ||||||
|           return await uploadMedia(mediaFile) |           return await uploadMedia(mediaFile); | ||||||
|         }), |         }) | ||||||
|       ) |       ); | ||||||
|  |  | ||||||
|       // Привязываем все медиа к статье |       // Привязываем все медиа к статье | ||||||
|       await Promise.all( |       await Promise.all( | ||||||
|         mediaIds.map((mediaId, index) => |         mediaIds.map((mediaId, index) => | ||||||
|           axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, { |           axiosInstance.post( | ||||||
|             media_id: mediaId, |             `${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`, | ||||||
|             media_order: index + 1, |             { | ||||||
|           }), |               media_id: mediaId, | ||||||
|         ), |               media_order: index + 1, | ||||||
|       ) |             } | ||||||
|  |           ) | ||||||
|       resetItem() |         ) | ||||||
|       setMediaFiles([]) |       ); | ||||||
|       window.location.reload() |       if (noReset) { | ||||||
|  |         setValue("heading", ""); | ||||||
|  |         setValue("body", ""); | ||||||
|  |       } else { | ||||||
|  |         resetItem(); | ||||||
|  |       } | ||||||
|  |       if (onSave) { | ||||||
|  |         onSave(response.data); | ||||||
|  |         if (notification && typeof notification.open === "function") { | ||||||
|  |           notification.open({ | ||||||
|  |             message: "Статья успешно создана", | ||||||
|  |             type: "success", | ||||||
|  |           }); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         window.location.reload(); | ||||||
|  |       } | ||||||
|     } catch (err: any) { |     } catch (err: any) { | ||||||
|       console.error('Error creating item:', err) |       console.error("Error creating item:", err); | ||||||
|     } |     } | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   const removeMedia = (index: number) => { |   const removeMedia = (index: number) => { | ||||||
|     setMediaFiles((prev) => { |     setMediaFiles((prev) => { | ||||||
|       const newFiles = [...prev] |       const newFiles = [...prev]; | ||||||
|       URL.revokeObjectURL(newFiles[index].preview) |       URL.revokeObjectURL(newFiles[index].preview); | ||||||
|       newFiles.splice(index, 1) |       newFiles.splice(index, 1); | ||||||
|       return newFiles |       return newFiles; | ||||||
|     }) |     }); | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Accordion> |     <Box> | ||||||
|       <AccordionSummary |       <TextField | ||||||
|         expandIcon={<ExpandMoreIcon />} |         {...registerItem("heading", { | ||||||
|  |           required: "Это поле является обязательным", | ||||||
|  |         })} | ||||||
|  |         error={!!(itemErrors as any)?.heading} | ||||||
|  |         helperText={(itemErrors as any)?.heading?.message} | ||||||
|  |         margin="normal" | ||||||
|  |         fullWidth | ||||||
|  |         slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |         type="text" | ||||||
|         sx={{ |         sx={{ | ||||||
|           marginTop: 2, |           backgroundColor: theme.palette.background.paper, | ||||||
|           background: theme.palette.background.paper, |  | ||||||
|           borderBottom: `1px solid ${theme.palette.divider}`, |  | ||||||
|         }} |         }} | ||||||
|       > |         label="Заголовок *" | ||||||
|         <Typography variant="subtitle1" fontWeight="bold"> |       /> | ||||||
|           Создать {title} |  | ||||||
|         </Typography> |       <Controller | ||||||
|       </AccordionSummary> |         control={controlItem} | ||||||
|       <AccordionDetails sx={{background: theme.palette.background.paper}}> |         name="body" | ||||||
|         <Box component="form" onSubmit={handleSubmitItem(handleCreate)}> |         rules={{ required: "Это поле является обязательным" }} | ||||||
|           <TextField |         defaultValue="" | ||||||
|             {...registerItem('heading', { |         render={({ field: { onChange, value } }) => ( | ||||||
|               required: 'Это поле является обязательным', |           <MemoizedSimpleMDE | ||||||
|             })} |             value={value} | ||||||
|             error={!!(itemErrors as any)?.heading} |             onChange={onChange} | ||||||
|             helperText={(itemErrors as any)?.heading?.message} |             options={simpleMDEOptions} | ||||||
|             margin="normal" |             className="my-markdown-editor" | ||||||
|             fullWidth |  | ||||||
|             InputLabelProps={{shrink: true}} |  | ||||||
|             type="text" |  | ||||||
|             label="Заголовок *" |  | ||||||
|           /> |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |  | ||||||
|           <Controller control={controlItem} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} /> |       {/* Dropzone для медиа файлов */} | ||||||
|  |       <Box sx={{ mt: 2, mb: 2 }}> | ||||||
|  |         <Box | ||||||
|  |           {...getRootProps()} | ||||||
|  |           sx={{ | ||||||
|  |             border: "2px dashed", | ||||||
|  |             borderColor: isDragActive ? "primary.main" : "grey.300", | ||||||
|  |             borderRadius: 1, | ||||||
|  |             p: 2, | ||||||
|  |             textAlign: "center", | ||||||
|  |             cursor: "pointer", | ||||||
|  |             "&:hover": { | ||||||
|  |               borderColor: "primary.main", | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <input {...getInputProps()} /> | ||||||
|  |           <Typography> | ||||||
|  |             {isDragActive | ||||||
|  |               ? "Перетащите файлы сюда..." | ||||||
|  |               : "Перетащите файлы сюда или кликните для выбора"} | ||||||
|  |           </Typography> | ||||||
|  |         </Box> | ||||||
|  |  | ||||||
|           {/* Dropzone для медиа файлов */} |         {/* Превью загруженных файлов */} | ||||||
|           <Box sx={{mt: 2, mb: 2}}> |         <Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}> | ||||||
|  |           {mediaFiles.map((mediaFile, index) => ( | ||||||
|             <Box |             <Box | ||||||
|               {...getRootProps()} |               key={mediaFile.preview} | ||||||
|               sx={{ |               sx={{ | ||||||
|                 border: '2px dashed', |                 position: "relative", | ||||||
|                 borderColor: isDragActive ? 'primary.main' : 'grey.300', |                 width: 100, | ||||||
|                 borderRadius: 1, |                 height: 100, | ||||||
|                 p: 2, |  | ||||||
|                 textAlign: 'center', |  | ||||||
|                 cursor: 'pointer', |  | ||||||
|                 '&:hover': { |  | ||||||
|                   borderColor: 'primary.main', |  | ||||||
|                 }, |  | ||||||
|               }} |               }} | ||||||
|             > |             > | ||||||
|               <input {...getInputProps()} /> |               {mediaFile.file.type.startsWith("image/") ? ( | ||||||
|               <Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography> |                 <img | ||||||
|             </Box> |                   src={mediaFile.preview} | ||||||
|  |                   alt={mediaFile.file.name} | ||||||
|             {/* Превью загруженных файлов */} |                   style={{ | ||||||
|             <Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}> |                     width: "100%", | ||||||
|               {mediaFiles.map((mediaFile, index) => ( |                     height: "100%", | ||||||
|  |                     objectFit: "cover", | ||||||
|  |                   }} | ||||||
|  |                 /> | ||||||
|  |               ) : ( | ||||||
|                 <Box |                 <Box | ||||||
|                   key={mediaFile.preview} |  | ||||||
|                   sx={{ |                   sx={{ | ||||||
|                     position: 'relative', |                     width: "100%", | ||||||
|                     width: 100, |                     height: "100%", | ||||||
|                     height: 100, |                     display: "flex", | ||||||
|  |                     alignItems: "center", | ||||||
|  |                     justifyContent: "center", | ||||||
|  |                     bgcolor: "grey.200", | ||||||
|                   }} |                   }} | ||||||
|                 > |                 > | ||||||
|                   {mediaFile.file.type.startsWith('image/') ? ( |                   <Typography variant="caption"> | ||||||
|                     <img |                     {mediaFile.file.name} | ||||||
|                       src={mediaFile.preview} |                   </Typography> | ||||||
|                       alt={mediaFile.file.name} |  | ||||||
|                       style={{ |  | ||||||
|                         width: '100%', |  | ||||||
|                         height: '100%', |  | ||||||
|                         objectFit: 'cover', |  | ||||||
|                       }} |  | ||||||
|                     /> |  | ||||||
|                   ) : ( |  | ||||||
|                     <Box |  | ||||||
|                       sx={{ |  | ||||||
|                         width: '100%', |  | ||||||
|                         height: '100%', |  | ||||||
|                         display: 'flex', |  | ||||||
|                         alignItems: 'center', |  | ||||||
|                         justifyContent: 'center', |  | ||||||
|                         bgcolor: 'grey.200', |  | ||||||
|                       }} |  | ||||||
|                     > |  | ||||||
|                       <Typography variant="caption">{mediaFile.file.name}</Typography> |  | ||||||
|                     </Box> |  | ||||||
|                   )} |  | ||||||
|                   <Button |  | ||||||
|                     size="small" |  | ||||||
|                     color="error" |  | ||||||
|                     onClick={() => removeMedia(index)} |  | ||||||
|                     sx={{ |  | ||||||
|                       position: 'absolute', |  | ||||||
|                       top: 0, |  | ||||||
|                       right: 0, |  | ||||||
|                       minWidth: 'auto', |  | ||||||
|                       width: 20, |  | ||||||
|                       height: 20, |  | ||||||
|                       p: 0, |  | ||||||
|                     }} |  | ||||||
|                   > |  | ||||||
|                     × |  | ||||||
|                   </Button> |  | ||||||
|                 </Box> |                 </Box> | ||||||
|               ))} |               )} | ||||||
|  |               <Button | ||||||
|  |                 size="small" | ||||||
|  |                 color="error" | ||||||
|  |                 onClick={() => removeMedia(index)} | ||||||
|  |                 sx={{ | ||||||
|  |                   position: "absolute", | ||||||
|  |                   top: 0, | ||||||
|  |                   right: 0, | ||||||
|  |                   minWidth: "auto", | ||||||
|  |                   width: 20, | ||||||
|  |                   height: 20, | ||||||
|  |                   p: 0, | ||||||
|  |                 }} | ||||||
|  |               > | ||||||
|  |                 × | ||||||
|  |               </Button> | ||||||
|             </Box> |             </Box> | ||||||
|           </Box> |           ))} | ||||||
|  |  | ||||||
|           <Box sx={{mt: 2, display: 'flex', gap: 2}}> |  | ||||||
|             <Button variant="contained" color="primary" type="submit"> |  | ||||||
|               Создать |  | ||||||
|             </Button> |  | ||||||
|             <Button |  | ||||||
|               variant="outlined" |  | ||||||
|               onClick={() => { |  | ||||||
|                 resetItem() |  | ||||||
|                 mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview)) |  | ||||||
|                 setMediaFiles([]) |  | ||||||
|               }} |  | ||||||
|             > |  | ||||||
|               Очистить |  | ||||||
|             </Button> |  | ||||||
|           </Box> |  | ||||||
|         </Box> |         </Box> | ||||||
|       </AccordionDetails> |       </Box> | ||||||
|     </Accordion> |  | ||||||
|   ) |       <Box sx={{ mt: 2, display: "flex", gap: 2 }}> | ||||||
| } |         <Button | ||||||
|  |           variant="contained" | ||||||
|  |           color="primary" | ||||||
|  |           type="submit" | ||||||
|  |           onClick={handleSubmitItem(handleCreate)} | ||||||
|  |         > | ||||||
|  |           Создать | ||||||
|  |         </Button> | ||||||
|  |         <Button | ||||||
|  |           variant="outlined" | ||||||
|  |           onClick={() => { | ||||||
|  |             resetItem(); | ||||||
|  |             mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview)); | ||||||
|  |             setMediaFiles([]); | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           Очистить | ||||||
|  |         </Button> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -1,83 +1,142 @@ | |||||||
| import {DataGrid, type DataGridProps, type GridColumnVisibilityModel} from '@mui/x-data-grid' | import { | ||||||
| import {Stack, Button, Typography} from '@mui/material' |   DataGrid, | ||||||
| import {ExportButton} from '@refinedev/mui' |   type DataGridProps, | ||||||
| import {useExport} from '@refinedev/core' |   type GridColumnVisibilityModel, | ||||||
| import React, {useState, useEffect, useMemo} from 'react' | } from "@mui/x-data-grid"; | ||||||
| import Cookies from 'js-cookie' | import { Stack, Button, Typography, Box } from "@mui/material"; | ||||||
|  | import { ExportButton } from "@refinedev/mui"; | ||||||
|  | import { useExport } from "@refinedev/core"; | ||||||
|  | import React, { useState, useEffect, useMemo } from "react"; | ||||||
|  | import Cookies from "js-cookie"; | ||||||
|  |  | ||||||
| import {localeText} from '../locales/ru/localeText' | import { localeText } from "../locales/ru/localeText"; | ||||||
|  | import { languageStore } from "../store/LanguageStore"; | ||||||
|  | import { LanguageSwitch } from "./LanguageSwitch"; | ||||||
|  |  | ||||||
| interface CustomDataGridProps extends DataGridProps { | interface CustomDataGridProps extends DataGridProps { | ||||||
|   hasCoordinates?: boolean |   hasCoordinates?: boolean; | ||||||
|   resource?: string // Add this prop |   resource?: string; // Add this prop | ||||||
|  |   languageEnabled?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| const DEV_FIELDS = ['id', 'code', 'country_code', 'city_id', 'carrier_id', 'main_color', 'left_color', 'right_color', 'logo', 'slogan', 'filename', 'arms', 'thumbnail', 'route_sys_number', 'governor_appeal', 'scale_min', 'scale_max', 'rotate', 'center_latitude', 'center_longitude', 'watermark_lu', 'watermark_rd', 'left_article', 'preview_article', 'offset_x', 'offset_y'] as const | const DEV_FIELDS = [ | ||||||
|  |   "id", | ||||||
|  |   "code", | ||||||
|  |   "country_code", | ||||||
|  |   "city_id", | ||||||
|  |   "carrier_id", | ||||||
|  |   "main_color", | ||||||
|  |   "left_color", | ||||||
|  |   "right_color", | ||||||
|  |   "logo", | ||||||
|  |   "slogan", | ||||||
|  |   "filename", | ||||||
|  |   "arms", | ||||||
|  |   "thumbnail", | ||||||
|  |   "route_sys_number", | ||||||
|  |   "governor_appeal", | ||||||
|  |   "scale_min", | ||||||
|  |   "scale_max", | ||||||
|  |   "rotate", | ||||||
|  |   "center_latitude", | ||||||
|  |   "center_longitude", | ||||||
|  |   "watermark_lu", | ||||||
|  |   "watermark_rd", | ||||||
|  |   "left_article", | ||||||
|  |   "preview_article", | ||||||
|  |   "offset_x", | ||||||
|  |   "offset_y", | ||||||
|  | ] as const; | ||||||
|  |  | ||||||
| export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource, ...props}: CustomDataGridProps) => { | export const CustomDataGrid = ({ | ||||||
|  |   languageEnabled = false, | ||||||
|  |   hasCoordinates = false, | ||||||
|  |   columns = [], | ||||||
|  |   resource, | ||||||
|  |   ...props | ||||||
|  | }: CustomDataGridProps) => { | ||||||
|   // const isDev = import.meta.env.DEV |   // const isDev = import.meta.env.DEV | ||||||
|   const {triggerExport, isLoading: exportLoading} = useExport({ |   const { triggerExport, isLoading: exportLoading } = useExport({ | ||||||
|     resource: resource ?? '', |     resource: resource ?? "", | ||||||
|     // pageSize: 100, #* |     // pageSize: 100, #* | ||||||
|     // maxItemCount: 100, #* |     // maxItemCount: 100, #* | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   const initialShowCoordinates = Cookies.get('showCoordinates') === 'true' |   const initialShowCoordinates = Cookies.get("showCoordinates") === "true"; | ||||||
|   const initialShowDevData = false // Default to false in both prod and dev |   const initialShowDevData = false; // Default to false in both prod and dev | ||||||
|   const [showCoordinates, setShowCoordinates] = useState(initialShowCoordinates) |   const [showCoordinates, setShowCoordinates] = useState( | ||||||
|   const [showDevData, setShowDevData] = useState(Cookies.get('showDevData') === 'true') |     initialShowCoordinates | ||||||
|  |   ); | ||||||
|  |   const [showDevData, setShowDevData] = useState( | ||||||
|  |     Cookies.get("showDevData") === "true" | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const availableDevFields = useMemo(() => DEV_FIELDS.filter((field) => columns.some((column) => column.field === field)), [columns]) |   const availableDevFields = useMemo( | ||||||
|  |     () => | ||||||
|  |       DEV_FIELDS.filter((field) => | ||||||
|  |         columns.some((column) => column.field === field) | ||||||
|  |       ), | ||||||
|  |     [columns] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const initialVisibilityModel = useMemo(() => { |   const initialVisibilityModel = useMemo(() => { | ||||||
|     const model: GridColumnVisibilityModel = {} |     const model: GridColumnVisibilityModel = {}; | ||||||
|  |  | ||||||
|     availableDevFields.forEach((field) => { |     availableDevFields.forEach((field) => { | ||||||
|       model[field] = initialShowDevData |       model[field] = initialShowDevData; | ||||||
|     }) |     }); | ||||||
|  |  | ||||||
|     if (hasCoordinates) { |     if (hasCoordinates) { | ||||||
|       model.latitude = initialShowCoordinates |       model.latitude = initialShowCoordinates; | ||||||
|       model.longitude = initialShowCoordinates |       model.longitude = initialShowCoordinates; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return model |     return model; | ||||||
|   }, [availableDevFields, hasCoordinates, initialShowCoordinates, initialShowDevData]) |   }, [ | ||||||
|  |     availableDevFields, | ||||||
|  |     hasCoordinates, | ||||||
|  |     initialShowCoordinates, | ||||||
|  |     initialShowDevData, | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|   const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(initialVisibilityModel) |   const [columnVisibilityModel, setColumnVisibilityModel] = | ||||||
|  |     useState<GridColumnVisibilityModel>(initialVisibilityModel); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setColumnVisibilityModel((prevModel) => { |     setColumnVisibilityModel((prevModel) => { | ||||||
|       const newModel = {...prevModel} |       const newModel = { ...prevModel }; | ||||||
|  |  | ||||||
|       availableDevFields.forEach((field) => { |       availableDevFields.forEach((field) => { | ||||||
|         newModel[field] = showDevData |         newModel[field] = showDevData; | ||||||
|       }) |       }); | ||||||
|  |  | ||||||
|       if (hasCoordinates) { |       if (hasCoordinates) { | ||||||
|         newModel.latitude = showCoordinates |         newModel.latitude = showCoordinates; | ||||||
|         newModel.longitude = showCoordinates |         newModel.longitude = showCoordinates; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       return newModel |       return newModel; | ||||||
|     }) |     }); | ||||||
|  |  | ||||||
|     if (hasCoordinates) { |     if (hasCoordinates) { | ||||||
|       Cookies.set('showCoordinates', String(showCoordinates)) |       Cookies.set("showCoordinates", String(showCoordinates)); | ||||||
|     } |     } | ||||||
|     Cookies.set('showDevData', String(showDevData)) |     Cookies.set("showDevData", String(showDevData)); | ||||||
|   }, [showCoordinates, showDevData, hasCoordinates, availableDevFields]) |   }, [showCoordinates, showDevData, hasCoordinates, availableDevFields]); | ||||||
|  |  | ||||||
|   const toggleCoordinates = () => { |   const toggleCoordinates = () => { | ||||||
|     setShowCoordinates((prev) => !prev) |     setShowCoordinates((prev) => !prev); | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   const toggleDevData = () => { |   const toggleDevData = () => { | ||||||
|     setShowDevData((prev) => !prev) |     setShowDevData((prev) => !prev); | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Stack spacing={2}> |     <Stack spacing={2}> | ||||||
|  |       <Box sx={{ visibility: languageEnabled ? "visible" : "hidden" }}> | ||||||
|  |         <LanguageSwitch /> | ||||||
|  |       </Box> | ||||||
|       <DataGrid |       <DataGrid | ||||||
|         {...props} |         {...props} | ||||||
|         columns={columns} |         columns={columns} | ||||||
| @@ -92,31 +151,37 @@ export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource, | |||||||
|           //   paginationModel: {pageSize: 25, page: 0}, |           //   paginationModel: {pageSize: 25, page: 0}, | ||||||
|           // }, |           // }, | ||||||
|           sorting: { |           sorting: { | ||||||
|             sortModel: [{field: 'id', sort: 'asc'}], |             sortModel: [{ field: "id", sort: "asc" }], | ||||||
|           }, |           }, | ||||||
|         }} |         }} | ||||||
|         pageSizeOptions={[10, 25, 50, 100]} |         pageSizeOptions={[10, 25, 50, 100]} | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|       <Stack direction="row" spacing={2} justifyContent="space-between" mb={2}> |       <Stack direction="row" spacing={2} justifyContent="space-between" mb={2}> | ||||||
|         <Stack direction="row" spacing={2} sx={{mb: 2}}> |         <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | ||||||
|           {hasCoordinates && ( |           {hasCoordinates && ( | ||||||
|             <Button variant="contained" onClick={toggleCoordinates}> |             <Button variant="contained" onClick={toggleCoordinates}> | ||||||
|               {showCoordinates ? 'Скрыть координаты' : 'Показать координаты'} |               {showCoordinates ? "Скрыть координаты" : "Показать координаты"} | ||||||
|             </Button> |             </Button> | ||||||
|           )} |           )} | ||||||
|  |  | ||||||
|           {(import.meta.env.DEV || showDevData) && availableDevFields.length > 0 && ( |           {(import.meta.env.DEV || showDevData) && | ||||||
|             <Button variant="contained" onClick={toggleDevData}> |             availableDevFields.length > 0 && ( | ||||||
|               {showDevData ? 'Скрыть служебные данные' : 'Показать служебные данные'} |               <Button variant="contained" onClick={toggleDevData}> | ||||||
|             </Button> |                 {showDevData | ||||||
|           )} |                   ? "Скрыть служебные данные" | ||||||
|  |                   : "Показать служебные данные"} | ||||||
|  |               </Button> | ||||||
|  |             )} | ||||||
|         </Stack> |         </Stack> | ||||||
|  |  | ||||||
|         <ExportButton onClick={triggerExport} loading={exportLoading} hideText={false}> |         <ExportButton | ||||||
|           <Typography sx={{marginLeft: '-2px'}}>Экспорт</Typography> |           onClick={triggerExport} | ||||||
|  |           loading={exportLoading} | ||||||
|  |           hideText={false} | ||||||
|  |         > | ||||||
|  |           <Typography sx={{ marginLeft: "-2px" }}>Экспорт</Typography> | ||||||
|         </ExportButton> |         </ExportButton> | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Stack> |     </Stack> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
							
								
								
									
										68
									
								
								src/components/LanguageSwitch/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/components/LanguageSwitch/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | |||||||
|  | import { Box } from "@mui/material"; | ||||||
|  | import { Languages, languageStore } from "../../store/LanguageStore"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
|  | export const LanguageSwitch = observer(({ action }: any) => { | ||||||
|  |   const { language, setLanguageAction } = languageStore; | ||||||
|  |  | ||||||
|  |   const handleLanguageChange = (lang: Languages) => { | ||||||
|  |     action?.(); | ||||||
|  |     setLanguageAction(lang); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box | ||||||
|  |       sx={{ | ||||||
|  |         flex: 1, | ||||||
|  |         display: "flex", | ||||||
|  |         gap: 2, | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <Box | ||||||
|  |         sx={{ | ||||||
|  |           cursor: "pointer", | ||||||
|  |           flex: 1, | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           bgcolor: language === "ru" ? "primary.main" : "transparent", | ||||||
|  |           color: language === "ru" ? "white" : "inherit", | ||||||
|  |           borderRadius: 1, | ||||||
|  |           p: 1, | ||||||
|  |         }} | ||||||
|  |         onClick={() => handleLanguageChange("ru")} | ||||||
|  |       > | ||||||
|  |         RU | ||||||
|  |       </Box> | ||||||
|  |       <Box | ||||||
|  |         sx={{ | ||||||
|  |           cursor: "pointer", | ||||||
|  |           flex: 1, | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           bgcolor: language === "en" ? "primary.main" : "transparent", | ||||||
|  |           color: language === "en" ? "white" : "inherit", | ||||||
|  |           borderRadius: 1, | ||||||
|  |           p: 1, | ||||||
|  |         }} | ||||||
|  |         onClick={() => handleLanguageChange("en")} | ||||||
|  |       > | ||||||
|  |         EN | ||||||
|  |       </Box> | ||||||
|  |       <Box | ||||||
|  |         sx={{ | ||||||
|  |           cursor: "pointer", | ||||||
|  |           flex: 1, | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           bgcolor: language === "zh" ? "primary.main" : "transparent", | ||||||
|  |           color: language === "zh" ? "white" : "inherit", | ||||||
|  |           borderRadius: 1, | ||||||
|  |           p: 1, | ||||||
|  |         }} | ||||||
|  |         onClick={() => handleLanguageChange("zh")} | ||||||
|  |       > | ||||||
|  |         ZH | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
| @@ -1,275 +1,558 @@ | |||||||
| import {useState, useEffect} from 'react' | import { useState, useEffect } from "react"; | ||||||
| import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material' | import { languageStore } from "../store/LanguageStore"; | ||||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore' | import { | ||||||
| import {axiosInstance} from '../providers/data' |   Stack, | ||||||
| import {BACKEND_URL} from '../lib/constants' |   Typography, | ||||||
| import {Link} from 'react-router' |   Button, | ||||||
| import {TOKEN_KEY} from '../authProvider' |   FormControl, | ||||||
|  |   Accordion, | ||||||
|  |   AccordionSummary, | ||||||
|  |   AccordionDetails, | ||||||
|  |   useTheme, | ||||||
|  |   TextField, | ||||||
|  |   Autocomplete, | ||||||
|  |   TableCell, | ||||||
|  |   TableContainer, | ||||||
|  |   Table, | ||||||
|  |   TableHead, | ||||||
|  |   TableRow, | ||||||
|  |   Paper, | ||||||
|  |   TableBody, | ||||||
|  |   IconButton, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||||
|  | import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; | ||||||
|  | import { axiosInstance } from "../providers/data"; | ||||||
|  |  | ||||||
|  | import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; | ||||||
|  |  | ||||||
|  | import { articleStore } from "../store/ArticleStore"; | ||||||
|  | import { ArticleEditModal } from "./modals/ArticleEditModal"; | ||||||
|  | import { StationEditModal } from "./modals/StationEditModal"; | ||||||
|  | import { stationStore } from "../store/StationStore"; | ||||||
|  |  | ||||||
|  | function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] { | ||||||
|  |   const index = pos - 1; | ||||||
|  |   if (index >= arr.length) { | ||||||
|  |     arr.push(value); | ||||||
|  |   } else { | ||||||
|  |     arr.splice(index, 0, value); | ||||||
|  |   } | ||||||
|  |   return arr; | ||||||
|  | } | ||||||
|  |  | ||||||
| type Field<T> = { | type Field<T> = { | ||||||
|   label: string |   label: string; | ||||||
|   data: keyof T |   data: keyof T; | ||||||
|   render?: (value: any) => React.ReactNode |   render?: (value: any) => React.ReactNode; | ||||||
| } | }; | ||||||
|  |  | ||||||
| type ExtraFieldConfig = { | type ExtraFieldConfig = { | ||||||
|   type: 'number' |   type: "number"; | ||||||
|   label: string |   label: string; | ||||||
|   minValue: number |   minValue: number; | ||||||
|   maxValue: (linkedItems: any[]) => number |   maxValue: (linkedItems: any[]) => number; | ||||||
| } | }; | ||||||
|  |  | ||||||
| type LinkedItemsProps<T> = { | type LinkedItemsProps<T> = { | ||||||
|   parentId: string | number |   parentId: string | number; | ||||||
|   parentResource: string |   parentResource: string; | ||||||
|   childResource: string |   childResource: string; | ||||||
|   fields: Field<T>[] |   fields: Field<T>[]; | ||||||
|   title: string |   setItemsParent?: (items: T[]) => void; | ||||||
|   type: 'show' | 'edit' |   title: string; | ||||||
|   extraField?: ExtraFieldConfig |   type: "show" | "edit"; | ||||||
| } |   extraField?: ExtraFieldConfig; | ||||||
|  |   dragAllowed?: boolean; | ||||||
|  |   onSave?: (items: T[]) => void; | ||||||
|  |   onUpdate?: () => void; | ||||||
|  |   dontRecurse?: boolean; | ||||||
|  |   disableCreation?: boolean; | ||||||
|  |   updatedLinkedItems?: T[]; | ||||||
|  |   refresh?: number; | ||||||
|  |   cityId?: number; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => { | const reorder = (list: any[], startIndex: number, endIndex: number) => { | ||||||
|   const [items, setItems] = useState<T[]>([]) |   const result = Array.from(list); | ||||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]) |   const [removed] = result.splice(startIndex, 1); | ||||||
|   const [selectedItemId, setSelectedItemId] = useState<number | null>(null) |   result.splice(endIndex, 0, removed); | ||||||
|   const [pageNum, setPageNum] = useState<number>(1) |   return result; | ||||||
|   const [isLoading, setIsLoading] = useState<boolean>(true) | }; | ||||||
|   const [mediaOrder, setMediaOrder] = useState<number>(1) |  | ||||||
|   const theme = useTheme() | export const LinkedItems = <T extends { id: number; [key: string]: any }>( | ||||||
|  |   props: LinkedItemsProps<T> | ||||||
|  | ) => { | ||||||
|  |   const theme = useTheme(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Accordion> | ||||||
|  |         <AccordionSummary | ||||||
|  |           expandIcon={<ExpandMoreIcon />} | ||||||
|  |           sx={{ | ||||||
|  |             background: theme.palette.background.paper, | ||||||
|  |             borderBottom: `1px solid ${theme.palette.divider}`, | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <Typography variant="subtitle1" fontWeight="bold"> | ||||||
|  |             Привязанные {props.title} | ||||||
|  |           </Typography> | ||||||
|  |         </AccordionSummary> | ||||||
|  |  | ||||||
|  |         <AccordionDetails sx={{ background: theme.palette.background.paper }}> | ||||||
|  |           <Stack gap={2}> | ||||||
|  |             <LinkedItemsContents {...props} /> | ||||||
|  |           </Stack> | ||||||
|  |         </AccordionDetails> | ||||||
|  |       </Accordion> | ||||||
|  |  | ||||||
|  |       {!props.dontRecurse && ( | ||||||
|  |         <> | ||||||
|  |           <ArticleEditModal /> | ||||||
|  |           <StationEditModal /> | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const LinkedItemsContents = < | ||||||
|  |   T extends { id: number; [key: string]: any } | ||||||
|  | >({ | ||||||
|  |   parentId, | ||||||
|  |   parentResource, | ||||||
|  |   childResource, | ||||||
|  |   setItemsParent, | ||||||
|  |   fields, | ||||||
|  |   title, | ||||||
|  |   dragAllowed = false, | ||||||
|  |   type, | ||||||
|  |   onUpdate, | ||||||
|  |   disableCreation = false, | ||||||
|  |   updatedLinkedItems, | ||||||
|  |   refresh, | ||||||
|  |   cityId, | ||||||
|  | }: LinkedItemsProps<T>) => { | ||||||
|  |   const { language } = languageStore; | ||||||
|  |   const { setArticleModalOpenAction, setArticleIdAction } = articleStore; | ||||||
|  |   const { setStationModalOpenAction, setStationIdAction, setRouteIdAction } = | ||||||
|  |     stationStore; | ||||||
|  |   const [position, setPosition] = useState<number>(1); | ||||||
|  |   const [items, setItems] = useState<T[]>([]); | ||||||
|  |   const [linkedItems, setLinkedItems] = useState<T[]>([]); | ||||||
|  |   const [selectedItemId, setSelectedItemId] = useState<number | null>(null); | ||||||
|  |   const [pageNum, setPageNum] = useState<number>(1); | ||||||
|  |   const [isLoading, setIsLoading] = useState<boolean>(true); | ||||||
|  |   const [mediaOrder, setMediaOrder] = useState<number>(1); | ||||||
|  |  | ||||||
|  |   let availableItems = items.filter( | ||||||
|  |     (item) => !linkedItems.some((linked) => linked.id === item.id) | ||||||
|  |   ); | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (childResource == "station") { | ||||||
|  |       availableItems = availableItems.sort((a, b) => | ||||||
|  |         a.name.localeCompare(b.name) | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   }, [childResource, availableItems]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!updatedLinkedItems?.length) return; | ||||||
|  |     setLinkedItems(updatedLinkedItems); | ||||||
|  |   }, [updatedLinkedItems]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setItemsParent?.(linkedItems); | ||||||
|  |   }, [linkedItems, setItemsParent]); | ||||||
|  |  | ||||||
|  |   const onDragEnd = (result: any) => { | ||||||
|  |     if (!result.destination) return; | ||||||
|  |  | ||||||
|  |     const reorderedItems = reorder( | ||||||
|  |       linkedItems, | ||||||
|  |       result.source.index, | ||||||
|  |       result.destination.index | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     setLinkedItems(reorderedItems); | ||||||
|  |  | ||||||
|  |     if (parentResource === "sight" && childResource === "article") { | ||||||
|  |       axiosInstance.post( | ||||||
|  |         `${import.meta.env.VITE_KRBL_API}/sight/${parentId}/article/order`, | ||||||
|  |         { | ||||||
|  |           articles: reorderedItems.map((item) => ({ | ||||||
|  |             id: item.id, | ||||||
|  |           })), | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       axiosInstance.post( | ||||||
|  |         `${import.meta.env.VITE_KRBL_API}/route/${parentId}/station`, | ||||||
|  |         { | ||||||
|  |           stations: reorderedItems.map((item) => ({ | ||||||
|  |             id: item.id, | ||||||
|  |           })), | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (parentId) { |     if (parentId) { | ||||||
|       axiosInstance |       axiosInstance | ||||||
|         .get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) |         .get( | ||||||
|  |           `${ | ||||||
|  |             import.meta.env.VITE_KRBL_API | ||||||
|  |           }/${parentResource}/${parentId}/${childResource}` | ||||||
|  |         ) | ||||||
|         .then((response) => { |         .then((response) => { | ||||||
|           setLinkedItems(response?.data || []) |           setLinkedItems(response?.data || []); | ||||||
|         }) |         }) | ||||||
|         .catch(() => { |         .catch(() => { | ||||||
|           setLinkedItems([]) |           setLinkedItems([]); | ||||||
|         }) |         }); | ||||||
|     } |     } | ||||||
|   }, [parentId, parentResource, childResource]) |   }, [parentId, parentResource, childResource, language, refresh]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (type === 'edit') { |     if (type === "edit") { | ||||||
|       axiosInstance |       axiosInstance | ||||||
|         .get(`${BACKEND_URL}/${childResource}/`) |         .get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`, {}) | ||||||
|         .then((response) => { |         .then((response) => { | ||||||
|           setItems(response?.data || []) |           setItems(response?.data || []); | ||||||
|           setIsLoading(false) |           setIsLoading(false); | ||||||
|         }) |         }) | ||||||
|         .catch(() => { |         .catch(() => { | ||||||
|           setItems([]) |           setItems([]); | ||||||
|           setIsLoading(false) |           setIsLoading(false); | ||||||
|         }) |         }); | ||||||
|     } else { |     } else { | ||||||
|       setIsLoading(false) |       setIsLoading(false); | ||||||
|     } |     } | ||||||
|   }, [childResource, type]) |   }, [childResource, type]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (childResource === 'article' && parentResource === 'sight') { |     if (childResource === "article" && parentResource === "sight") { | ||||||
|       setPageNum(linkedItems.length + 1) |       setPageNum(linkedItems.length + 1); | ||||||
|     } |     } | ||||||
|   }, [linkedItems, childResource, parentResource]) |   }, [linkedItems, childResource, parentResource]); | ||||||
|  |  | ||||||
|   const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) |  | ||||||
|  |  | ||||||
|   const linkItem = () => { |   const linkItem = () => { | ||||||
|     if (selectedItemId !== null) { |     if (selectedItemId !== null) { | ||||||
|       const requestData = |       const requestData = | ||||||
|         childResource === 'article' |         childResource === "article" | ||||||
|           ? { |           ? { | ||||||
|               [`${childResource}_id`]: selectedItemId, |               [`${childResource}_id`]: selectedItemId, | ||||||
|               page_num: pageNum, |               page_num: pageNum, | ||||||
|             } |             } | ||||||
|           : childResource === 'media' |           : childResource === "media" | ||||||
|           ? { |           ? { | ||||||
|               [`${childResource}_id`]: selectedItemId, |               [`${childResource}_id`]: selectedItemId, | ||||||
|               media_order: mediaOrder, |               media_order: mediaOrder, | ||||||
|             } |             } | ||||||
|           : { |           : childResource === "station" | ||||||
|               [`${childResource}_id`]: selectedItemId, |           ? { | ||||||
|  |               stations: insertAtPosition( | ||||||
|  |                 linkedItems.map((item) => ({ | ||||||
|  |                   id: item.id, | ||||||
|  |                 })), | ||||||
|  |                 position, | ||||||
|  |                 { | ||||||
|  |                   id: selectedItemId, | ||||||
|  |                 } | ||||||
|  |               ), | ||||||
|             } |             } | ||||||
|  |           : { [`${childResource}_id`]: selectedItemId }; | ||||||
|  |  | ||||||
|       axiosInstance |       axiosInstance | ||||||
|         .post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData) |         .post( | ||||||
|  |           `${ | ||||||
|  |             import.meta.env.VITE_KRBL_API | ||||||
|  |           }/${parentResource}/${parentId}/${childResource}`, | ||||||
|  |           requestData | ||||||
|  |         ) | ||||||
|         .then(() => { |         .then(() => { | ||||||
|           axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { |           axiosInstance | ||||||
|             setLinkedItems(response?.data || []) |             .get( | ||||||
|             setSelectedItemId(null) |               `${ | ||||||
|             if (childResource === 'article') { |                 import.meta.env.VITE_KRBL_API | ||||||
|               setPageNum(pageNum + 1) |               }/${parentResource}/${parentId}/${childResource}` | ||||||
|             } |             ) | ||||||
|           }) |             .then((response) => { | ||||||
|  |               setLinkedItems(response?.data || []); | ||||||
|  |               setSelectedItemId(null); | ||||||
|  |               if (childResource === "article") { | ||||||
|  |                 setPageNum(pageNum + 1); | ||||||
|  |               } | ||||||
|  |               onUpdate?.(); | ||||||
|  |             }); | ||||||
|         }) |         }) | ||||||
|         .catch((error) => { |         .catch((error) => { | ||||||
|           console.error('Error linking item:', error) |           console.error("Error linking item:", error); | ||||||
|         }) |         }); | ||||||
|     } |     } | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   const deleteItem = (itemId: number) => { |   const deleteItem = (itemId: number) => { | ||||||
|     axiosInstance |     axiosInstance | ||||||
|       .delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, { |       .delete( | ||||||
|         data: {[`${childResource}_id`]: itemId}, |         `${ | ||||||
|       }) |           import.meta.env.VITE_KRBL_API | ||||||
|  |         }/${parentResource}/${parentId}/${childResource}`, | ||||||
|  |         { | ||||||
|  |           data: { [`${childResource}_id`]: itemId }, | ||||||
|  |         } | ||||||
|  |       ) | ||||||
|       .then(() => { |       .then(() => { | ||||||
|         setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)) |         setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)); | ||||||
|  |         onUpdate?.(); | ||||||
|       }) |       }) | ||||||
|       .catch((error) => { |       .catch((error) => { | ||||||
|         console.error('Error unlinking item:', error) |         console.error("Error unlinking item:", error); | ||||||
|       }) |       }); | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Accordion> |     <> | ||||||
|       <AccordionSummary |       {linkedItems?.length > 0 && ( | ||||||
|         expandIcon={<ExpandMoreIcon />} |         <DragDropContext onDragEnd={onDragEnd}> | ||||||
|         sx={{ |           <TableContainer component={Paper}> | ||||||
|           background: theme.palette.background.paper, |             <Table> | ||||||
|           borderBottom: `1px solid ${theme.palette.divider}`, |               <TableHead> | ||||||
|         }} |                 <TableRow> | ||||||
|       > |                   {type === "edit" && dragAllowed && ( | ||||||
|         <Typography variant="subtitle1" fontWeight="bold"> |                     <TableCell width="40px"></TableCell> | ||||||
|           Привязанные {title} |                   )} | ||||||
|         </Typography> |                   <TableCell key="id">№</TableCell> | ||||||
|       </AccordionSummary> |                   {fields.map((field) => ( | ||||||
|  |                     <TableCell key={String(field.data)}> | ||||||
|  |                       {field.label} | ||||||
|  |                     </TableCell> | ||||||
|  |                   ))} | ||||||
|  |  | ||||||
|       <AccordionDetails sx={{background: theme.palette.background.paper}}> |                   {type === "edit" && ( | ||||||
|         <Stack gap={2}> |                     <TableCell width="120px">Действие</TableCell> | ||||||
|           <Grid container gap={1.25}> |                   )} | ||||||
|             {isLoading ? ( |                 </TableRow> | ||||||
|               <Typography>Загрузка...</Typography> |               </TableHead> | ||||||
|             ) : linkedItems.length > 0 ? ( |  | ||||||
|               linkedItems.map((item, index) => ( |               <Droppable | ||||||
|                 <Box |                 droppableId="droppable" | ||||||
|                   component={Link} |                 isDropDisabled={type !== "edit" || !dragAllowed} | ||||||
|                   to={`/${childResource}/show/${item.id}`} |               > | ||||||
|                   key={index} |                 {(provided) => ( | ||||||
|                   sx={{ |                   <TableBody | ||||||
|                     marginTop: '8px', |                     ref={provided.innerRef} | ||||||
|                     padding: '14px', |                     {...provided.droppableProps} | ||||||
|                     borderRadius: 2, |                   > | ||||||
|                     border: `2px solid ${theme.palette.divider}`, |                     {linkedItems.map((item, index) => ( | ||||||
|                     width: childResource === 'article' ? '100%' : 'auto', |                       <Draggable | ||||||
|                     textDecoration: 'none', |                         key={item.id} | ||||||
|                     color: 'inherit', |                         draggableId={"q" + String(item.id)} | ||||||
|                     display: 'block', |                         index={index} | ||||||
|                     '&:hover': { |                         isDragDisabled={type !== "edit" || !dragAllowed} | ||||||
|                       backgroundColor: theme.palette.action.hover, |  | ||||||
|                     }, |  | ||||||
|                   }} |  | ||||||
|                 > |  | ||||||
|                   <Stack gap={0.25}> |  | ||||||
|                     {childResource === 'media' && item.id && ( |  | ||||||
|                       <img |  | ||||||
|                         src={`https://wn.krbl.ru/media/${item.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`} |  | ||||||
|                         alt={String(item.media_name)} |  | ||||||
|                         style={{ |  | ||||||
|                           width: '100%', |  | ||||||
|                           height: '120px', |  | ||||||
|                           objectFit: 'contain', |  | ||||||
|                           marginBottom: '8px', |  | ||||||
|                           borderRadius: 4, |  | ||||||
|                         }} |  | ||||||
|                       /> |  | ||||||
|                     )} |  | ||||||
|                     {fields.map(({label, data, render}) => ( |  | ||||||
|                       <Typography variant="body2" color="textSecondary" key={String(data)}> |  | ||||||
|                         <strong>{label}:</strong> {render ? render(item[data]) : item[data]} |  | ||||||
|                       </Typography> |  | ||||||
|                     ))} |  | ||||||
|                     {type === 'edit' && ( |  | ||||||
|                       <Button |  | ||||||
|                         variant="outlined" |  | ||||||
|                         color="error" |  | ||||||
|                         size="small" |  | ||||||
|                         onClick={(e) => { |  | ||||||
|                           e.preventDefault() |  | ||||||
|                           deleteItem(item.id) |  | ||||||
|                         }} |  | ||||||
|                         sx={{mt: 1.5}} |  | ||||||
|                       > |                       > | ||||||
|                         Отвязать |                         {(provided) => ( | ||||||
|                       </Button> |                           <TableRow | ||||||
|                     )} |                             sx={{ | ||||||
|                   </Stack> |                               cursor: | ||||||
|                 </Box> |                                 childResource === "article" | ||||||
|               )) |                                   ? "pointer" | ||||||
|             ) : ( |                                   : "default", | ||||||
|               <Typography color="textSecondary">{title} не найдены</Typography> |                             }} | ||||||
|  |                             onClick={() => { | ||||||
|  |                               if ( | ||||||
|  |                                 childResource === "article" && | ||||||
|  |                                 type === "edit" | ||||||
|  |                               ) { | ||||||
|  |                                 setArticleModalOpenAction(true); | ||||||
|  |                                 setArticleIdAction(item.id); | ||||||
|  |                               } | ||||||
|  |                               if ( | ||||||
|  |                                 childResource === "station" && | ||||||
|  |                                 type === "edit" | ||||||
|  |                               ) { | ||||||
|  |                                 setStationModalOpenAction(true); | ||||||
|  |                                 setStationIdAction(item.id); | ||||||
|  |                                 setRouteIdAction(Number(parentId)); | ||||||
|  |                               } | ||||||
|  |                             }} | ||||||
|  |                             ref={provided.innerRef} | ||||||
|  |                             {...provided.draggableProps} | ||||||
|  |                             {...provided.dragHandleProps} | ||||||
|  |                             hover | ||||||
|  |                           > | ||||||
|  |                             {type === "edit" && dragAllowed && ( | ||||||
|  |                               <TableCell {...provided.dragHandleProps}> | ||||||
|  |                                 <IconButton size="small"> | ||||||
|  |                                   <DragIndicatorIcon /> | ||||||
|  |                                 </IconButton> | ||||||
|  |                               </TableCell> | ||||||
|  |                             )} | ||||||
|  |                             <TableCell key={String(item.id)}> | ||||||
|  |                               {index + 1} | ||||||
|  |                             </TableCell> | ||||||
|  |                             {fields.map((field, index) => ( | ||||||
|  |                               <TableCell | ||||||
|  |                                 key={String(field.data) + String(index)} | ||||||
|  |                               > | ||||||
|  |                                 {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}> | ||||||
|  |           {title} не найдены | ||||||
|  |         </Typography> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {type === "edit" && !disableCreation && ( | ||||||
|  |         <Stack gap={2} mt={2}> | ||||||
|  |           <Typography variant="subtitle1">Добавить {title}</Typography> | ||||||
|  |           <Autocomplete | ||||||
|  |             fullWidth | ||||||
|  |             value={ | ||||||
|  |               availableItems?.find((item) => item.id === selectedItemId) || null | ||||||
|  |             } | ||||||
|  |             onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} | ||||||
|  |             options={availableItems.filter((item) => item.city_id == cityId)} | ||||||
|  |             getOptionLabel={(item) => String(item[fields[0].data])} | ||||||
|  |             renderInput={(params) => ( | ||||||
|  |               <TextField {...params} label={`Выберите ${title}`} fullWidth /> | ||||||
|             )} |             )} | ||||||
|           </Grid> |             isOptionEqualToValue={(option, value) => option.id === value?.id} | ||||||
|  |             filterOptions={(options, { inputValue }) => { | ||||||
|  |               const searchWords = inputValue | ||||||
|  |                 .toLowerCase() | ||||||
|  |                 .split(" ") | ||||||
|  |                 .filter((word) => word.length > 0); | ||||||
|  |  | ||||||
|           {type === 'edit' && ( |               return options.filter((option) => { | ||||||
|             <Stack gap={2}> |                 const optionWords = String(option[fields[0].data]) | ||||||
|               <Typography variant="subtitle1">Добавить {title}</Typography> |                   .toLowerCase() | ||||||
|               <Autocomplete |                   .split(" "); | ||||||
|                 fullWidth |                 return searchWords.every((searchWord) => | ||||||
|                 value={availableItems.find((item) => item.id === selectedItemId) || null} |                   optionWords.some((word) => word.startsWith(searchWord)) | ||||||
|                 onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} |                 ); | ||||||
|                 options={availableItems} |               }); | ||||||
|                 getOptionLabel={(item) => String(item[fields[0].data])} |             }} | ||||||
|                 renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />} |             renderOption={(props, option) => ( | ||||||
|                 isOptionEqualToValue={(option, value) => option.id === value?.id} |               <li {...props} key={option.id}> | ||||||
|                 filterOptions={(options, {inputValue}) => { |                 {String(option[fields[0].data])} | ||||||
|                   // return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase())) |               </li> | ||||||
|                   const searchWords = inputValue |             )} | ||||||
|                     .toLowerCase() |           /> | ||||||
|                     .split(' ') |  | ||||||
|                     .filter((word) => word.length > 0) |           {/* {childResource === "article" && ( | ||||||
|                   return options.filter((option) => { |             <FormControl fullWidth> | ||||||
|                     const optionWords = String(option[fields[0].data]).toLowerCase().split(' ') |               <TextField | ||||||
|                     return searchWords.every((searchWord) => optionWords.some((word) => word.startsWith(searchWord))) |                 type="number" | ||||||
|                   }) |                 label="Позиция добавляемой статьи" | ||||||
|  |                 name="page_num" | ||||||
|  |                 value={pageNum} | ||||||
|  |                 onChange={(e) => { | ||||||
|  |                   const newValue = Number(e.target.value); | ||||||
|  |                   const minValue = linkedItems.length + 1; | ||||||
|  |                   setPageNum(newValue < minValue ? minValue : newValue); | ||||||
|                 }} |                 }} | ||||||
|  |                 fullWidth | ||||||
|  |                 InputLabelProps={{ shrink: true }} | ||||||
|               /> |               /> | ||||||
|  |             </FormControl> | ||||||
|  |           )} */} | ||||||
|  |  | ||||||
|               {childResource === 'article' && ( |           {childResource === "media" && ( | ||||||
|                 <FormControl fullWidth> |             <FormControl fullWidth> | ||||||
|                   <TextField |               <TextField | ||||||
|                     type="number" |                 type="text" | ||||||
|                     label="Номер страницы" |                 label="Порядок отображения медиа" | ||||||
|                     name="page_num" |                 value={mediaOrder} | ||||||
|                     value={pageNum} |                 onChange={(e) => { | ||||||
|                     onChange={(e) => { |                   const rawValue = e.target.value; | ||||||
|                       const newValue = Number(e.target.value) |                   const numericValue = Number(rawValue); | ||||||
|                       const minValue = linkedItems.length + 1 // page number on articles lenght |                   const maxValue = linkedItems.length + 1; | ||||||
|                       setPageNum(newValue < minValue ? minValue : newValue) |  | ||||||
|                     }} |  | ||||||
|                     fullWidth |  | ||||||
|                     InputLabelProps={{shrink: true}} |  | ||||||
|                   /> |  | ||||||
|                 </FormControl> |  | ||||||
|               )} |  | ||||||
|  |  | ||||||
|               {childResource === 'media' && type === 'edit' && ( |                   if (isNaN(numericValue)) { | ||||||
|                 <FormControl fullWidth> |                     return; | ||||||
|                   <TextField |                   } else { | ||||||
|                     type="number" |                     let newValue = numericValue; | ||||||
|                     label="Порядок отображения медиа" |  | ||||||
|                     value={mediaOrder} |  | ||||||
|                     onChange={(e) => { |  | ||||||
|                       const newValue = Number(e.target.value) |  | ||||||
|                       const maxValue = linkedItems.length + 1 |  | ||||||
|                       const value = Math.max(1, Math.min(newValue, maxValue)) |  | ||||||
|                       setMediaOrder(value) |  | ||||||
|                     }} |  | ||||||
|                     fullWidth |  | ||||||
|                     InputLabelProps={{shrink: true}} |  | ||||||
|                   /> |  | ||||||
|                 </FormControl> |  | ||||||
|               )} |  | ||||||
|  |  | ||||||
|               <Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> |                     if (newValue < 10 && newValue > 0) { | ||||||
|                 Добавить |                       setMediaOrder(numericValue); | ||||||
|               </Button> |                     } | ||||||
|             </Stack> |  | ||||||
|  |                     if (newValue > maxValue) { | ||||||
|  |                       newValue = maxValue; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     setMediaOrder(newValue); | ||||||
|  |                   } | ||||||
|  |                 }} | ||||||
|  |                 fullWidth | ||||||
|  |                 InputLabelProps={{ shrink: true }} | ||||||
|  |               /> | ||||||
|  |             </FormControl> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           <Button | ||||||
|  |             variant="contained" | ||||||
|  |             onClick={linkItem} | ||||||
|  |             disabled={ | ||||||
|  |               !selectedItemId || (childResource == "media" && mediaOrder == 0) | ||||||
|  |             } | ||||||
|  |             sx={{ alignSelf: "flex-start" }} | ||||||
|  |           > | ||||||
|  |             Добавить | ||||||
|  |           </Button> | ||||||
|  |           {childResource == "station" && ( | ||||||
|  |             <TextField | ||||||
|  |               type="text" | ||||||
|  |               label="Позиция добавляемой остановки к маршруту" | ||||||
|  |               value={position} | ||||||
|  |               onChange={(e) => { | ||||||
|  |                 const newValue = Number(e.target.value); | ||||||
|  |                 setPosition( | ||||||
|  |                   newValue > linkedItems.length + 1 | ||||||
|  |                     ? linkedItems.length + 1 | ||||||
|  |                     : newValue | ||||||
|  |                 ); | ||||||
|  |               }} | ||||||
|  |             ></TextField> | ||||||
|           )} |           )} | ||||||
|         </Stack> |         </Stack> | ||||||
|       </AccordionDetails> |       )} | ||||||
|     </Accordion> |     </> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import {styled} from '@mui/material/styles' | import {styled} from '@mui/material/styles' | ||||||
| import zIndex from '@mui/material/styles/zIndex' | import zIndex from '@mui/material/styles/zIndex' | ||||||
| import SimpleMDE, {SimpleMDEReactProps} from 'react-simplemde-editor' | import SimpleMDE, {SimpleMDEReactProps, default as SimpleMDEDefault} from 'react-simplemde-editor' | ||||||
|  |  | ||||||
| const StyledMarkdownEditor = styled('div')(({theme}) => ({ | const StyledMarkdownEditor = styled('div')(({theme}) => ({ | ||||||
|   '& .editor-toolbar': { |   '& .editor-toolbar': { | ||||||
| @@ -63,10 +63,46 @@ const StyledMarkdownEditor = styled('div')(({theme}) => ({ | |||||||
|   '& .guide': { |   '& .guide': { | ||||||
|     display: 'none', |     display: 'none', | ||||||
|   }, |   }, | ||||||
| })) | })); | ||||||
|  |  | ||||||
| export const MarkdownEditor = (props: SimpleMDEReactProps) => ( | export const MarkdownEditor = (props: SimpleMDEReactProps) => { | ||||||
|   <StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}> |   if(props.options) | ||||||
|     <SimpleMDE {...props} /> |     props.options.toolbar = [ | ||||||
|   </StyledMarkdownEditor> |       "bold", | ||||||
| ) |       "italic", | ||||||
|  |       "strikethrough", | ||||||
|  |       { | ||||||
|  |         name: "Underline", | ||||||
|  |         action: (editor: any) => { | ||||||
|  |  | ||||||
|  |           const cm = editor.codemirror; | ||||||
|  |           let output = ''; | ||||||
|  |           const selectedText = cm.getSelection(); | ||||||
|  |           const text = selectedText ?? 'placeholder'; | ||||||
|  |          | ||||||
|  |           output = '<u>' + text + '</u>'; | ||||||
|  |           cm.replaceSelection(output); | ||||||
|  |          | ||||||
|  |         }, | ||||||
|  |         className: "fa fa-underline", // Look for a suitable icon | ||||||
|  |         title: "Underline (Ctrl/Cmd-Alt-U)", | ||||||
|  |       }, | ||||||
|  |       "heading", | ||||||
|  |       "quote", | ||||||
|  |       "unordered-list", | ||||||
|  |       "ordered-list", | ||||||
|  |       "link", | ||||||
|  |       "image", | ||||||
|  |       "code", | ||||||
|  |       "table", | ||||||
|  |       "horizontal-rule", | ||||||
|  |       "preview", | ||||||
|  |       "fullscreen", | ||||||
|  |       "guide" | ||||||
|  |     ] | ||||||
|  |   return ( | ||||||
|  |     <StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}> | ||||||
|  |       <SimpleMDE {...props}/> | ||||||
|  |     </StyledMarkdownEditor> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,181 +1,235 @@ | |||||||
| import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined' | import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; | ||||||
| import LightModeOutlined from '@mui/icons-material/LightModeOutlined' | import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; | ||||||
| import AppBar from '@mui/material/AppBar' | import AppBar from "@mui/material/AppBar"; | ||||||
| import Avatar from '@mui/material/Avatar' | import Avatar from "@mui/material/Avatar"; | ||||||
| import IconButton from '@mui/material/IconButton' | import IconButton from "@mui/material/IconButton"; | ||||||
| import Stack from '@mui/material/Stack' | import Stack from "@mui/material/Stack"; | ||||||
| import Toolbar from '@mui/material/Toolbar' | import Toolbar from "@mui/material/Toolbar"; | ||||||
| import Typography from '@mui/material/Typography' | import Typography from "@mui/material/Typography"; | ||||||
| import {useGetIdentity, usePermissions, useWarnAboutChange} from '@refinedev/core' | import { languageStore } from "../../store/LanguageStore"; | ||||||
| import {HamburgerMenu, RefineThemedLayoutV2HeaderProps} from '@refinedev/mui' | import { | ||||||
| import React, {useContext, useEffect} from 'react' |   useGetIdentity, | ||||||
| import {ColorModeContext} from '../../contexts/color-mode' |   useList, | ||||||
| import Cookies from 'js-cookie' |   usePermissions, | ||||||
| import {useTranslation} from 'react-i18next' |   useWarnAboutChange, | ||||||
| import {Button} from '@mui/material' | } from "@refinedev/core"; | ||||||
| import {useNavigate} from 'react-router' | import { HamburgerMenu, RefineThemedLayoutV2HeaderProps } from "@refinedev/mui"; | ||||||
|  | import React, { useContext, useEffect, useState } from "react"; | ||||||
|  | import { ColorModeContext } from "../../contexts/color-mode"; | ||||||
|  | import Cookies from "js-cookie"; | ||||||
|  | import { useTranslation } from "react-i18next"; | ||||||
|  | import { | ||||||
|  |   Button, | ||||||
|  |   Select, | ||||||
|  |   MenuItem, | ||||||
|  |   FormControl, | ||||||
|  |   SelectChangeEvent, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { useNavigate } from "react-router"; | ||||||
|  | import { cityStore } from "../../store/CityStore"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
| type IUser = { | type IUser = { | ||||||
|   id: number |   id: number; | ||||||
|   name: string |   name: string; | ||||||
|   avatar: string |   avatar: string; | ||||||
|   is_admin: boolean |   is_admin: boolean; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true}) => { | export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer( | ||||||
|   const {mode, setMode} = useContext(ColorModeContext) |   ({ sticky }) => { | ||||||
|   const {data: user} = useGetIdentity<IUser>() |     const { city_id, setCityIdAction } = cityStore; | ||||||
|   const {data: permissions} = usePermissions<string[]>() |     const { language } = languageStore; | ||||||
|   const isAdmin = permissions?.includes('admin') |     const { data: cities } = useList({ | ||||||
|   const {i18n} = useTranslation() |       resource: "city", | ||||||
|   const {setWarnWhen, warnWhen} = useWarnAboutChange() |     }); | ||||||
|  |  | ||||||
|   const navigate = useNavigate() |     const { mode, setMode } = useContext(ColorModeContext); | ||||||
|  |     const { data: user } = useGetIdentity<IUser>(); | ||||||
|  |     const { data: permissions } = usePermissions<string[]>(); | ||||||
|  |     const isAdmin = permissions?.includes("admin"); | ||||||
|  |     const { i18n } = useTranslation(); | ||||||
|  |     const { setWarnWhen, warnWhen } = useWarnAboutChange(); | ||||||
|  |     const navigate = useNavigate(); | ||||||
|  |  | ||||||
|   const handleLanguageChange = async (lang: string) => { |     const handleChange = (event: SelectChangeEvent<string>) => { | ||||||
|     // console.log('Language change requested:', lang) |       setCityIdAction(event.target.value); | ||||||
|     // console.log('Current warnWhen state:', warnWhen) |     }; | ||||||
|  |  | ||||||
|     const form = document.querySelector('form') |     const handleLanguageChange = async (lang: string) => { | ||||||
|     const inputs = form?.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input, textarea, select') |       // console.log('Language change requested:', lang) | ||||||
|     const saveButton = document.querySelector('.refine-save-button') as HTMLButtonElement |       // console.log('Current warnWhen state:', warnWhen) | ||||||
|  |  | ||||||
|     // Сохраняем текущий URL перед любыми действиями |       const form = document.querySelector("form"); | ||||||
|     const currentLocation = window.location.pathname + window.location.search |       const inputs = form?.querySelectorAll< | ||||||
|  |         HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | ||||||
|  |       >("input, textarea, select"); | ||||||
|  |       const saveButton = document.querySelector( | ||||||
|  |         ".refine-save-button" | ||||||
|  |       ) as HTMLButtonElement; | ||||||
|  |  | ||||||
|     if (form && saveButton) { |       // Сохраняем текущий URL перед любыми действиями | ||||||
|       const hasChanges = Array.from(inputs || []).some((input) => { |       const currentLocation = window.location.pathname + window.location.search; | ||||||
|         if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { |  | ||||||
|           return input.value !== input.defaultValue |  | ||||||
|         } |  | ||||||
|         if (input instanceof HTMLSelectElement) { |  | ||||||
|           return input.value !== input.options[input.selectedIndex].defaultSelected.toString() |  | ||||||
|         } |  | ||||||
|         return false |  | ||||||
|       }) |  | ||||||
|  |  | ||||||
|       if (hasChanges || warnWhen) { |       if (form && saveButton) { | ||||||
|         try { |         const hasChanges = Array.from(inputs || []).some((input) => { | ||||||
|           // console.log('Attempting to save changes...') |           if ( | ||||||
|           setWarnWhen(false) |             input instanceof HTMLInputElement || | ||||||
|           saveButton.click() |             input instanceof HTMLTextAreaElement | ||||||
|           // console.log('Save button clicked') |           ) { | ||||||
|  |             return input.value !== input.defaultValue; | ||||||
|  |           } | ||||||
|  |           if (input instanceof HTMLSelectElement) { | ||||||
|  |             return ( | ||||||
|  |               input.value !== | ||||||
|  |               input.options[input.selectedIndex].defaultSelected.toString() | ||||||
|  |             ); | ||||||
|  |           } | ||||||
|  |           return false; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|           await new Promise((resolve) => setTimeout(resolve, 1000)) |         if (hasChanges || warnWhen) { | ||||||
|  |           try { | ||||||
|  |             // console.log('Attempting to save changes...') | ||||||
|  |             setWarnWhen(false); | ||||||
|  |             saveButton.click(); | ||||||
|  |             // console.log('Save button clicked') | ||||||
|  |  | ||||||
|           // После сохранения меняем язык и возвращаемся на ту же страницу |             await new Promise((resolve) => setTimeout(resolve, 1000)); | ||||||
|           Cookies.set('lang', lang) |  | ||||||
|           i18n.changeLanguage(lang) |             // После сохранения меняем язык и возвращаемся на ту же страницу | ||||||
|           navigate(currentLocation) |             Cookies.set("lang", lang); | ||||||
|           return |  | ||||||
|         } catch (error) { |             i18n.changeLanguage(lang); | ||||||
|           console.error('Failed to save form:', error) |             navigate(currentLocation); | ||||||
|           setWarnWhen(true) |             return; | ||||||
|           return |           } catch (error) { | ||||||
|  |             console.error("Failed to save form:", error); | ||||||
|  |             setWarnWhen(true); | ||||||
|  |             return; | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Если нет формы или изменений, просто меняем язык |       // Если нет формы или изменений, просто меняем язык | ||||||
|     // console.log('Setting language cookie:', lang) |       // console.log('Setting language cookie:', lang) | ||||||
|     Cookies.set('lang', lang) |       Cookies.set("lang", lang); | ||||||
|  |  | ||||||
|     // console.log('Changing i18n language') |       // console.log('Changing i18n language') | ||||||
|     i18n.changeLanguage(lang) |       i18n.changeLanguage(lang); | ||||||
|  |  | ||||||
|     // Используем текущий URL для навигации |       // Используем текущий URL для навигации | ||||||
|     navigate(0) |       navigate(0); | ||||||
|   } |     }; | ||||||
|  |  | ||||||
|   useEffect(() => { |     useEffect(() => { | ||||||
|     const savedLang = Cookies.get('lang') || 'ru' |       const savedLang = Cookies.get("lang") || "ru"; | ||||||
|     i18n.changeLanguage(savedLang) |       i18n.changeLanguage(savedLang); | ||||||
|   }, [i18n]) |     }, [i18n]); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <AppBar position={sticky ? "sticky" : "relative"}> | ||||||
|  |         <Toolbar> | ||||||
|  |           <Stack | ||||||
|  |             direction="row" | ||||||
|  |             width="100%" | ||||||
|  |             justifyContent="flex-end" | ||||||
|  |             alignItems="center" | ||||||
|  |           > | ||||||
|  |             <HamburgerMenu /> | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <AppBar position={sticky ? 'sticky' : 'relative'}> |  | ||||||
|       <Toolbar> |  | ||||||
|         <Stack direction="row" width="100%" justifyContent="flex-end" alignItems="center"> |  | ||||||
|           <HamburgerMenu /> |  | ||||||
|           <Stack direction="row" width="100%" justifyContent="flex-end" alignItems="center" spacing={2}> |  | ||||||
|             <Stack |             <Stack | ||||||
|               direction="row" |               direction="row" | ||||||
|               spacing={1} |               width="100%" | ||||||
|  |               justifyContent="flex-end" | ||||||
|  |               alignItems="center" | ||||||
|  |               spacing={2} | ||||||
|  |               color="white" | ||||||
|               sx={{ |               sx={{ | ||||||
|                 backgroundColor: 'background.paper', |                 "& .MuiSelect-select": { | ||||||
|                 padding: '4px', |                   color: "white", | ||||||
|                 borderRadius: '4px', |                 }, | ||||||
|               }} |               }} | ||||||
|             > |             > | ||||||
|               {['ru', 'en', 'zh'].map((lang) => ( |               <FormControl | ||||||
|                 <Button |                 variant="standard" | ||||||
|                   key={lang} |                 sx={{ width: "min-content", color: "white" }} | ||||||
|                   onClick={() => handleLanguageChange(lang)} |               > | ||||||
|                   variant={i18n.language === lang ? 'contained' : 'outlined'} |                 {city_id && cities && ( | ||||||
|                   size="small" |                   <Select | ||||||
|                   sx={{ |                     defaultValue={city_id} | ||||||
|                     minWidth: '30px', |                     value={city_id} | ||||||
|                     padding: '2px 0px', |                     onChange={handleChange} | ||||||
|                     textTransform: 'uppercase', |                   > | ||||||
|                   }} |                     <MenuItem value={String(0)} key={0}> | ||||||
|                 > |                       Все города | ||||||
|                   {lang} |                     </MenuItem> | ||||||
|                 </Button> |                     {cities.data?.map((city) => ( | ||||||
|               ))} |                       <MenuItem value={String(city.id)} key={city.id}> | ||||||
|             </Stack> |                         {city.name} | ||||||
|  |                       </MenuItem> | ||||||
|             <IconButton |                     ))} | ||||||
|               color="inherit" |                   </Select> | ||||||
|               onClick={() => { |  | ||||||
|                 setMode() |  | ||||||
|               }} |  | ||||||
|               sx={{ |  | ||||||
|                 marginRight: '2px', |  | ||||||
|               }} |  | ||||||
|             > |  | ||||||
|               {mode === 'dark' ? <LightModeOutlined /> : <DarkModeOutlined />} |  | ||||||
|             </IconButton> |  | ||||||
|  |  | ||||||
|             {(user?.avatar || user?.name) && ( |  | ||||||
|               <Stack direction="row" gap="16px" alignItems="center" justifyContent="center"> |  | ||||||
|                 {user?.name && ( |  | ||||||
|                   <Stack direction="column" alignItems="start" gap="0px"> |  | ||||||
|                     <Typography |  | ||||||
|                       sx={{ |  | ||||||
|                         display: { |  | ||||||
|                           xs: 'none', |  | ||||||
|                           sm: 'inline-block', |  | ||||||
|                         }, |  | ||||||
|                       }} |  | ||||||
|                       variant="subtitle2" |  | ||||||
|                     > |  | ||||||
|                       {user?.name} |  | ||||||
|                     </Typography> |  | ||||||
|  |  | ||||||
|                     <Typography |  | ||||||
|                       sx={{ |  | ||||||
|                         display: { |  | ||||||
|                           xs: 'none', |  | ||||||
|                           sm: 'inline-block', |  | ||||||
|                         }, |  | ||||||
|                         backgroundColor: 'primary.main', |  | ||||||
|                         color: 'rgba(255, 255, 255, 0.7)', |  | ||||||
|                         padding: '1px 4px', |  | ||||||
|                         borderRadius: 1, |  | ||||||
|                         fontSize: '0.6rem', |  | ||||||
|                       }} |  | ||||||
|                       variant="subtitle2" |  | ||||||
|                     > |  | ||||||
|                       {isAdmin ? 'Администратор' : 'Пользователь'} |  | ||||||
|                     </Typography> |  | ||||||
|                   </Stack> |  | ||||||
|                 )} |                 )} | ||||||
|                 <Avatar src={user?.avatar} alt={user?.name} /> |               </FormControl> | ||||||
|               </Stack> |  | ||||||
|             )} |               <IconButton | ||||||
|  |                 color="inherit" | ||||||
|  |                 onClick={() => { | ||||||
|  |                   setMode(); | ||||||
|  |                 }} | ||||||
|  |                 sx={{ | ||||||
|  |                   marginRight: "2px", | ||||||
|  |                 }} | ||||||
|  |               > | ||||||
|  |                 {mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />} | ||||||
|  |               </IconButton> | ||||||
|  |  | ||||||
|  |               {(user?.avatar || user?.name) && ( | ||||||
|  |                 <Stack | ||||||
|  |                   direction="row" | ||||||
|  |                   gap="16px" | ||||||
|  |                   alignItems="center" | ||||||
|  |                   justifyContent="center" | ||||||
|  |                 > | ||||||
|  |                   {user?.name && ( | ||||||
|  |                     <Stack direction="column" alignItems="start" gap="0px"> | ||||||
|  |                       <Typography | ||||||
|  |                         sx={{ | ||||||
|  |                           display: { | ||||||
|  |                             xs: "none", | ||||||
|  |                             sm: "inline-block", | ||||||
|  |                           }, | ||||||
|  |                         }} | ||||||
|  |                         variant="subtitle2" | ||||||
|  |                       > | ||||||
|  |                         {user?.name} | ||||||
|  |                       </Typography> | ||||||
|  |  | ||||||
|  |                       <Typography | ||||||
|  |                         sx={{ | ||||||
|  |                           display: { | ||||||
|  |                             xs: "none", | ||||||
|  |                             sm: "inline-block", | ||||||
|  |                           }, | ||||||
|  |                           backgroundColor: "primary.main", | ||||||
|  |                           color: "rgba(255, 255, 255, 0.7)", | ||||||
|  |                           padding: "1px 4px", | ||||||
|  |                           borderRadius: 1, | ||||||
|  |                           fontSize: "0.6rem", | ||||||
|  |                         }} | ||||||
|  |                         variant="subtitle2" | ||||||
|  |                       > | ||||||
|  |                         {isAdmin ? "Администратор" : "Пользователь"} | ||||||
|  |                       </Typography> | ||||||
|  |                     </Stack> | ||||||
|  |                   )} | ||||||
|  |                   <Avatar src={user?.avatar} alt={user?.name} /> | ||||||
|  |                 </Stack> | ||||||
|  |               )} | ||||||
|  |             </Stack> | ||||||
|           </Stack> |           </Stack> | ||||||
|         </Stack> |         </Toolbar> | ||||||
|       </Toolbar> |       </AppBar> | ||||||
|     </AppBar> |     ); | ||||||
|   ) |   } | ||||||
| } | ); | ||||||
|   | |||||||
| @@ -1 +1,5 @@ | |||||||
| export {Header} from './header' | export * from './AdminOnly' | ||||||
|  | export * from './CreateSightArticle' | ||||||
|  | export * from './CustomDataGrid' | ||||||
|  | export * from './LinkedItems' | ||||||
|  | export * from './MarkdownEditor' | ||||||
| @@ -1,72 +1,142 @@ | |||||||
| import {useState} from 'react' | import { useState } from "react"; | ||||||
| import {UseFormSetError, UseFormClearErrors, UseFormSetValue} from 'react-hook-form' | import { | ||||||
|  |   UseFormSetError, | ||||||
|  |   UseFormClearErrors, | ||||||
|  |   UseFormSetValue, | ||||||
|  | } from "react-hook-form"; | ||||||
|  |  | ||||||
| export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] | export const ALLOWED_IMAGE_TYPES = [ | ||||||
| export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg'] |   "image/jpeg", | ||||||
|  |   "image/png", | ||||||
|  |   "image/gif", | ||||||
|  |   "image/webp", | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"]; | ||||||
|  |  | ||||||
|  | export const ALLOWED_PANORAMA_TYPES = [ | ||||||
|  |   "image/jpeg", | ||||||
|  |   "image/jpg", | ||||||
|  |   "image/png", | ||||||
|  |   "image/gif", | ||||||
|  |   "image/webp", | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const ALLOWED_ICON_TYPES = [ | ||||||
|  |   "image/svg+xml", | ||||||
|  |   "image/png", | ||||||
|  |   "image/jpg", | ||||||
|  |   "image/jpeg", | ||||||
|  |   "image/webp", | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const ALLOWED_WATERMARK_TYPES = [ | ||||||
|  |   "image/svg+xml", | ||||||
|  |   "image/png", | ||||||
|  |   "image/jpg", | ||||||
|  |   "image/jpeg", | ||||||
|  |   "image/webp", | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const ALLOWED_3D_MODEL_TYPES = [ | ||||||
|  |   ".glb", | ||||||
|  |   "glb", | ||||||
|  |   ".gltf", | ||||||
|  |   "gltf", | ||||||
|  |   "model/gltf-binary", | ||||||
|  |   ".vnd.ms-3d", | ||||||
|  | ]; | ||||||
|  |  | ||||||
| export const validateFileType = (file: File, mediaType: number) => { | export const validateFileType = (file: File, mediaType: number) => { | ||||||
|   if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) { |   if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) { | ||||||
|     return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP' |     return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) { |   if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) { | ||||||
|     return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG' |     return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return null |   if (mediaType === 3 && !ALLOWED_ICON_TYPES.includes(file.type)) { | ||||||
| } |     return 'Для типа "Иконка" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (mediaType === 4 && !ALLOWED_WATERMARK_TYPES.includes(file.type)) { | ||||||
|  |     return 'Для типа "Водяной знак" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (mediaType === 5 && !ALLOWED_PANORAMA_TYPES.includes(file.type)) { | ||||||
|  |     return 'Для типа "Панорама" разрешены только форматы: JPG, PNG, GIF, WEBP'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (mediaType === 6 && !ALLOWED_3D_MODEL_TYPES.includes(file.type)) { | ||||||
|  |     const extension = file.name.split(".").pop(); | ||||||
|  |     const isMimeTypeValid = ["model/gltf-binary"].includes(file.type); | ||||||
|  |     const isExtensionValid = | ||||||
|  |       extension && ALLOWED_3D_MODEL_TYPES.includes(extension); | ||||||
|  |     if (!isMimeTypeValid && !isExtensionValid) { | ||||||
|  |       return 'Для типа "3D-модель" разрешены только форматы: GLB, GLTF'; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
|  |  | ||||||
| type UseMediaFileUploadProps = { | type UseMediaFileUploadProps = { | ||||||
|   selectedMediaType: number |   selectedMediaType: number; | ||||||
|   setError: UseFormSetError<any> |   setError: UseFormSetError<any>; | ||||||
|   clearErrors: UseFormClearErrors<any> |   clearErrors: UseFormClearErrors<any>; | ||||||
|   setValue: UseFormSetValue<any> |   setValue: UseFormSetValue<any>; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, setValue}: UseMediaFileUploadProps) => { | export const useMediaFileUpload = ({ | ||||||
|   const [selectedFile, setSelectedFile] = useState<File | null>(null) |   selectedMediaType, | ||||||
|   const [previewUrl, setPreviewUrl] = useState<string | null>(null) |   setError, | ||||||
|  |   clearErrors, | ||||||
|  |   setValue, | ||||||
|  | }: UseMediaFileUploadProps) => { | ||||||
|  |   const [selectedFile, setSelectedFile] = useState<File | null>(null); | ||||||
|  |   const [previewUrl, setPreviewUrl] = useState<string | null>(null); | ||||||
|  |  | ||||||
|   const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { |   const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|     const file = event.target.files?.[0] |     const file = event.target.files?.[0]; | ||||||
|     if (!file) return |     if (!file) return; | ||||||
|  |  | ||||||
|     if (selectedMediaType) { |     if (selectedMediaType) { | ||||||
|       const error = validateFileType(file, selectedMediaType) |       const error = validateFileType(file, selectedMediaType); | ||||||
|       if (error) { |       if (error) { | ||||||
|         setError('file', {type: 'manual', message: error}) |         setError("file", { type: "manual", message: error }); | ||||||
|         event.target.value = '' |         event.target.value = ""; | ||||||
|         return |         return; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     clearErrors('file') |     clearErrors("file"); | ||||||
|     setValue('file', file) |     setValue("file", file); | ||||||
|     setSelectedFile(file) |     setSelectedFile(file); | ||||||
|  |  | ||||||
|     if (file.type.startsWith('image/')) { |     if (file.type.startsWith("image/")) { | ||||||
|       const url = URL.createObjectURL(file) |       const url = URL.createObjectURL(file); | ||||||
|       setPreviewUrl(url) |       setPreviewUrl(url); | ||||||
|     } else { |     } else { | ||||||
|       setPreviewUrl(null) |       setPreviewUrl(null); | ||||||
|     } |     } | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   const handleMediaTypeChange = (newMediaType: number | null) => { |   const handleMediaTypeChange = (newMediaType: number | null) => { | ||||||
|     setValue('media_type', newMediaType || null) |     setValue("media_type", newMediaType || null); | ||||||
|  |  | ||||||
|     if (selectedFile && newMediaType) { |     if (selectedFile && newMediaType) { | ||||||
|       const error = validateFileType(selectedFile, newMediaType) |       const error = validateFileType(selectedFile, newMediaType); | ||||||
|       if (error) { |       if (error) { | ||||||
|         setError('file', {type: 'manual', message: error}) |         setError("file", { type: "manual", message: error }); | ||||||
|         setValue('file', null) |         setValue("file", null); | ||||||
|         setSelectedFile(null) |         setSelectedFile(null); | ||||||
|         setPreviewUrl(null) |         setPreviewUrl(null); | ||||||
|       } else { |       } else { | ||||||
|         clearErrors('file') |         clearErrors("file"); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|     selectedFile, |     selectedFile, | ||||||
| @@ -75,5 +145,5 @@ export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, se | |||||||
|     setPreviewUrl, |     setPreviewUrl, | ||||||
|     handleFileChange, |     handleFileChange, | ||||||
|     handleMediaTypeChange, |     handleMediaTypeChange, | ||||||
|   } |   }; | ||||||
| } | }; | ||||||
|   | |||||||
							
								
								
									
										410
									
								
								src/components/modals/ArticleEditModal/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										410
									
								
								src/components/modals/ArticleEditModal/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,410 @@ | |||||||
|  | import { Modal, Box, Button, TextField, Typography } from "@mui/material"; | ||||||
|  | import { articleStore } from "../../../store/ArticleStore"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  | import "easymde/dist/easymde.min.css"; | ||||||
|  | import { memo, useMemo, useEffect, useCallback, useState } from "react"; | ||||||
|  | import { MarkdownEditor } from "../../MarkdownEditor"; | ||||||
|  | import { Edit } from "@refinedev/mui"; | ||||||
|  | import { EVERY_LANGUAGE, languageStore } from "../../../store/LanguageStore"; | ||||||
|  | import { LanguageSwitch } from "../../LanguageSwitch/index"; | ||||||
|  | import { useDropzone } from "react-dropzone"; | ||||||
|  | import { | ||||||
|  |   ALLOWED_IMAGE_TYPES, | ||||||
|  |   ALLOWED_VIDEO_TYPES, | ||||||
|  | } from "../../media/MediaFormUtils"; | ||||||
|  | import { TOKEN_KEY, axiosInstance } from "@providers"; | ||||||
|  | import { LinkedItems } from "../../../components/LinkedItems"; | ||||||
|  | import { mediaFields, MediaItem } from "../../../pages/article/types"; | ||||||
|  |  | ||||||
|  | const MemoizedSimpleMDE = memo(MarkdownEditor); | ||||||
|  |  | ||||||
|  | type MediaFile = { | ||||||
|  |   file: File; | ||||||
|  |   preview: string; | ||||||
|  |   uploading: boolean; | ||||||
|  |   media_id?: number; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const style = { | ||||||
|  |   marginLeft: "auto", | ||||||
|  |   marginRight: "auto", | ||||||
|  |   //position: "absolute", | ||||||
|  |   //top: "50%", | ||||||
|  |   //left: "50%", | ||||||
|  |   //transform: "translate(-50%, -50%)", | ||||||
|  |   width: "60%", | ||||||
|  |   bgcolor: "background.paper", | ||||||
|  |   border: "2px solid #000", | ||||||
|  |   boxShadow: 24, | ||||||
|  |   p: 4, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ArticleEditModal = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|  |   const [articleData, setArticleData] = useState({ | ||||||
|  |     heading: EVERY_LANGUAGE(language), | ||||||
|  |     body: EVERY_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |   const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } = | ||||||
|  |     articleStore; | ||||||
|  |  | ||||||
|  |   const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]); | ||||||
|  |  | ||||||
|  |   const [refresh, setRefresh] = useState(0); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     return () => { | ||||||
|  |       setArticleModalOpenAction(false); | ||||||
|  |     }; | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   // Load existing media files when editing an article | ||||||
|  |   const loadExistingMedia = async () => { | ||||||
|  |     if (selectedArticleId) { | ||||||
|  |       try { | ||||||
|  |         const response = await axiosInstance.get( | ||||||
|  |           `${import.meta.env.VITE_KRBL_API}/article/${selectedArticleId}/media` | ||||||
|  |         ); | ||||||
|  |         const existingMedia = response.data; | ||||||
|  |  | ||||||
|  |         // Convert existing media to MediaFile format | ||||||
|  |         const mediaFiles = await Promise.all( | ||||||
|  |           existingMedia.map(async (media: any) => { | ||||||
|  |             const response = await fetch( | ||||||
|  |               `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |                 media.id | ||||||
|  |               }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||||
|  |             ); | ||||||
|  |             const blob = await response.blob(); | ||||||
|  |             const file = new File([blob], media.filename, { | ||||||
|  |               type: media.media_type === 1 ? "image/jpeg" : "video/mp4", | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             return { | ||||||
|  |               file, | ||||||
|  |               preview: URL.createObjectURL(blob), | ||||||
|  |               uploading: false, | ||||||
|  |               mediaId: media.id, | ||||||
|  |             }; | ||||||
|  |           }) | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         setMediaFiles(mediaFiles); | ||||||
|  |         setRefresh(refresh + 1); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error("Error loading existing media:", error); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     loadExistingMedia(); | ||||||
|  |   }, [selectedArticleId]); | ||||||
|  |  | ||||||
|  |   const { | ||||||
|  |     register, | ||||||
|  |     control, | ||||||
|  |     formState: { errors }, | ||||||
|  |     saveButtonProps, | ||||||
|  |     reset, | ||||||
|  |     setValue, | ||||||
|  |     watch, | ||||||
|  |   } = useForm({ | ||||||
|  |     refineCoreProps: { | ||||||
|  |       resource: "article", | ||||||
|  |       id: selectedArticleId ?? undefined, | ||||||
|  |       action: "edit", | ||||||
|  |       redirect: false, | ||||||
|  |  | ||||||
|  |       onMutationSuccess: async () => { | ||||||
|  |         try { | ||||||
|  |           // Upload new media files | ||||||
|  |           const newMediaFiles = mediaFiles.filter((file) => !file.media_id); | ||||||
|  |           const existingMediaAmount = mediaFiles.filter( | ||||||
|  |             (file) => file.media_id | ||||||
|  |           ).length; | ||||||
|  |           const mediaIds = await Promise.all( | ||||||
|  |             newMediaFiles.map(async (mediaFile) => { | ||||||
|  |               return await uploadMedia(mediaFile); | ||||||
|  |             }) | ||||||
|  |           ); | ||||||
|  |  | ||||||
|  |           // Associate all media with the article | ||||||
|  |           await Promise.all( | ||||||
|  |             mediaIds.map((mediaId, index) => | ||||||
|  |               axiosInstance.post( | ||||||
|  |                 `${ | ||||||
|  |                   import.meta.env.VITE_KRBL_API | ||||||
|  |                 }/article/${selectedArticleId}/media/`, | ||||||
|  |                 { | ||||||
|  |                   media_id: mediaId, | ||||||
|  |                   media_order: index + existingMediaAmount + 1, | ||||||
|  |                 } | ||||||
|  |               ) | ||||||
|  |             ) | ||||||
|  |           ); | ||||||
|  |  | ||||||
|  |           setArticleModalOpenAction(false); | ||||||
|  |           reset(); | ||||||
|  |           window.location.reload(); | ||||||
|  |         } catch (error) { | ||||||
|  |           console.error("Error handling media:", error); | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       meta: { | ||||||
|  |         headers: { | ||||||
|  |           "Accept-Language": language, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (articleData.heading[language]) { | ||||||
|  |       setValue("heading", articleData.heading[language]); | ||||||
|  |     } | ||||||
|  |     if (articleData.body[language]) { | ||||||
|  |       setValue("body", articleData.body[language]); | ||||||
|  |     } | ||||||
|  |   }, [language, articleData, setValue]); | ||||||
|  |  | ||||||
|  |   const handleLanguageChange = () => { | ||||||
|  |     setArticleData((prevData) => ({ | ||||||
|  |       ...prevData, | ||||||
|  |       heading: { | ||||||
|  |         ...prevData.heading, | ||||||
|  |         [language]: watch("heading") ?? "", | ||||||
|  |       }, | ||||||
|  |       body: { | ||||||
|  |         ...prevData.body, | ||||||
|  |         [language]: watch("body") ?? "", | ||||||
|  |       }, | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const simpleMDEOptions = useMemo( | ||||||
|  |     () => ({ | ||||||
|  |       placeholder: "Введите контент в формате Markdown...", | ||||||
|  |       spellChecker: false, | ||||||
|  |     }), | ||||||
|  |     [] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const onDrop = useCallback((acceptedFiles: File[]) => { | ||||||
|  |     const newFiles = acceptedFiles.map((file) => ({ | ||||||
|  |       file, | ||||||
|  |       preview: URL.createObjectURL(file), | ||||||
|  |       uploading: false, | ||||||
|  |     })); | ||||||
|  |     setMediaFiles((prev) => [...prev, ...newFiles]); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const { getRootProps, getInputProps, isDragActive } = useDropzone({ | ||||||
|  |     onDrop, | ||||||
|  |     accept: { | ||||||
|  |       "image/jpeg": [".jpeg", ".jpg"], | ||||||
|  |       "image/png": [".png"], | ||||||
|  |       "image/webp": [".webp"], | ||||||
|  |       "video/mp4": [".mp4"], | ||||||
|  |       "video/webm": [".webm"], | ||||||
|  |       "video/ogg": [".ogg"], | ||||||
|  |     }, | ||||||
|  |     multiple: true, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const uploadMedia = async (mediaFile: MediaFile) => { | ||||||
|  |     const formData = new FormData(); | ||||||
|  |     formData.append("media_name", mediaFile.file.name); | ||||||
|  |     formData.append("filename", mediaFile.file.name); | ||||||
|  |     formData.append( | ||||||
|  |       "type", | ||||||
|  |       mediaFile.file.type.startsWith("image/") ? "1" : "2" | ||||||
|  |     ); | ||||||
|  |     formData.append("file", mediaFile.file); | ||||||
|  |  | ||||||
|  |     const response = await axiosInstance.post( | ||||||
|  |       `${import.meta.env.VITE_KRBL_API}/media`, | ||||||
|  |       formData | ||||||
|  |     ); | ||||||
|  |     return response.data.id; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const removeMedia = async (index: number) => { | ||||||
|  |     const mediaFile = mediaFiles[index]; | ||||||
|  |  | ||||||
|  |     // If it's an existing media file (has mediaId), delete it from the server | ||||||
|  |     if (mediaFile.media_id) { | ||||||
|  |       try { | ||||||
|  |         await axiosInstance.delete( | ||||||
|  |           `${import.meta.env.VITE_KRBL_API}/media/${mediaFile.media_id}` | ||||||
|  |         ); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error("Error deleting media:", error); | ||||||
|  |         return; // Don't remove from UI if server deletion failed | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Remove from UI and cleanup | ||||||
|  |     setMediaFiles((prev) => { | ||||||
|  |       const newFiles = [...prev]; | ||||||
|  |       URL.revokeObjectURL(newFiles[index].preview); | ||||||
|  |       newFiles.splice(index, 1); | ||||||
|  |       return newFiles; | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Modal | ||||||
|  |       open={articleModalOpen} | ||||||
|  |       onClose={() => setArticleModalOpenAction(false)} | ||||||
|  |       aria-labelledby="modal-modal-title" | ||||||
|  |       aria-describedby="modal-modal-description" | ||||||
|  |       sx={{ overflow: "auto" }} | ||||||
|  |     > | ||||||
|  |       <Box sx={style}> | ||||||
|  |         <Edit | ||||||
|  |           title={<Typography variant="h5">Редактирование статьи</Typography>} | ||||||
|  |           headerProps={{ | ||||||
|  |             sx: { | ||||||
|  |               fontSize: "50px", | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |           saveButtonProps={saveButtonProps} | ||||||
|  |         > | ||||||
|  |           <LanguageSwitch action={handleLanguageChange} /> | ||||||
|  |           <Box | ||||||
|  |             component="form" | ||||||
|  |             sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |             autoComplete="off" | ||||||
|  |           > | ||||||
|  |             <TextField | ||||||
|  |               {...register("heading", { | ||||||
|  |                 required: "Это поле является обязательным", | ||||||
|  |               })} | ||||||
|  |               error={!!errors.heading} | ||||||
|  |               helperText={errors.heading?.message as string} | ||||||
|  |               margin="normal" | ||||||
|  |               fullWidth | ||||||
|  |               InputLabelProps={{ shrink: true }} | ||||||
|  |               type="text" | ||||||
|  |               name="heading" | ||||||
|  |               label="Заголовок *" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             <Controller | ||||||
|  |               control={control} | ||||||
|  |               name="body" | ||||||
|  |               rules={{ required: "Это поле является обязательным" }} | ||||||
|  |               defaultValue="" | ||||||
|  |               render={({ field: { onChange, value } }) => ( | ||||||
|  |                 <MemoizedSimpleMDE | ||||||
|  |                   value={value} | ||||||
|  |                   onChange={onChange} | ||||||
|  |                   options={simpleMDEOptions} | ||||||
|  |                   className="my-markdown-editor" | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             {selectedArticleId && ( | ||||||
|  |               <LinkedItems<MediaItem> | ||||||
|  |                 type="edit" | ||||||
|  |                 parentId={selectedArticleId} | ||||||
|  |                 parentResource="article" | ||||||
|  |                 childResource="media" | ||||||
|  |                 fields={mediaFields} | ||||||
|  |                 title="медиа" | ||||||
|  |                 dontRecurse | ||||||
|  |                 onUpdate={loadExistingMedia} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|  |           </Box> | ||||||
|  |  | ||||||
|  |           {/* Dropzone для медиа файлов */} | ||||||
|  |           <Box sx={{ mt: 2, mb: 2 }}> | ||||||
|  |             <Box | ||||||
|  |               {...getRootProps()} | ||||||
|  |               sx={{ | ||||||
|  |                 border: "2px dashed", | ||||||
|  |                 borderColor: isDragActive ? "primary.main" : "grey.300", | ||||||
|  |                 borderRadius: 1, | ||||||
|  |                 p: 2, | ||||||
|  |                 textAlign: "center", | ||||||
|  |                 cursor: "pointer", | ||||||
|  |                 "&:hover": { | ||||||
|  |                   borderColor: "primary.main", | ||||||
|  |                 }, | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               <input {...getInputProps()} /> | ||||||
|  |               <Typography> | ||||||
|  |                 {isDragActive | ||||||
|  |                   ? "Перетащите файлы сюда..." | ||||||
|  |                   : "Перетащите файлы сюда или кликните для выбора"} | ||||||
|  |               </Typography> | ||||||
|  |             </Box> | ||||||
|  |  | ||||||
|  |             {/* Превью загруженных файлов */} | ||||||
|  |             <Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}> | ||||||
|  |               {mediaFiles.map((mediaFile, index) => ( | ||||||
|  |                 <Box | ||||||
|  |                   key={mediaFile.preview} | ||||||
|  |                   sx={{ | ||||||
|  |                     position: "relative", | ||||||
|  |                     width: 100, | ||||||
|  |                     height: 100, | ||||||
|  |                   }} | ||||||
|  |                 > | ||||||
|  |                   {mediaFile.file.type.startsWith("image/") ? ( | ||||||
|  |                     <img | ||||||
|  |                       src={mediaFile.preview} | ||||||
|  |                       alt={mediaFile.file.name} | ||||||
|  |                       style={{ | ||||||
|  |                         width: "100%", | ||||||
|  |                         height: "100%", | ||||||
|  |                         objectFit: "cover", | ||||||
|  |                       }} | ||||||
|  |                     /> | ||||||
|  |                   ) : ( | ||||||
|  |                     <Box | ||||||
|  |                       sx={{ | ||||||
|  |                         width: "100%", | ||||||
|  |                         height: "100%", | ||||||
|  |                         display: "flex", | ||||||
|  |                         alignItems: "center", | ||||||
|  |                         justifyContent: "center", | ||||||
|  |                         bgcolor: "grey.200", | ||||||
|  |                       }} | ||||||
|  |                     > | ||||||
|  |                       <Typography variant="caption"> | ||||||
|  |                         {mediaFile.file.name} | ||||||
|  |                       </Typography> | ||||||
|  |                     </Box> | ||||||
|  |                   )} | ||||||
|  |                   <Button | ||||||
|  |                     size="small" | ||||||
|  |                     color="error" | ||||||
|  |                     onClick={() => removeMedia(index)} | ||||||
|  |                     sx={{ | ||||||
|  |                       position: "absolute", | ||||||
|  |                       top: 0, | ||||||
|  |                       right: 0, | ||||||
|  |                       minWidth: "auto", | ||||||
|  |                       width: 20, | ||||||
|  |                       height: 20, | ||||||
|  |                       p: 0, | ||||||
|  |                     }} | ||||||
|  |                   > | ||||||
|  |                     × | ||||||
|  |                   </Button> | ||||||
|  |                 </Box> | ||||||
|  |               ))} | ||||||
|  |             </Box> | ||||||
|  |           </Box> | ||||||
|  |         </Edit> | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										191
									
								
								src/components/modals/StationEditModal/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								src/components/modals/StationEditModal/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | |||||||
|  | import { | ||||||
|  |   Modal, | ||||||
|  |   Box, | ||||||
|  |   Button, | ||||||
|  |   TextField, | ||||||
|  |   Typography, | ||||||
|  |   Grid, | ||||||
|  |   Paper, | ||||||
|  | } from "@mui/material"; | ||||||
|  |  | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  | import "easymde/dist/easymde.min.css"; | ||||||
|  | import { memo, useMemo, useEffect } from "react"; | ||||||
|  | import { MarkdownEditor } from "../../MarkdownEditor"; | ||||||
|  | import { Edit } from "@refinedev/mui"; | ||||||
|  | import { languageStore } from "../../../store/LanguageStore"; | ||||||
|  | import { LanguageSwitch } from "../../LanguageSwitch/index"; | ||||||
|  |  | ||||||
|  | import { useState } from "react"; | ||||||
|  | import { stationStore } from "../../../store/StationStore"; | ||||||
|  | import { useCustom } from "@refinedev/core"; | ||||||
|  | import { useApiUrl } from "@refinedev/core"; | ||||||
|  | import { StationItem } from "src/pages/route/types"; | ||||||
|  | const MemoizedSimpleMDE = memo(MarkdownEditor); | ||||||
|  |  | ||||||
|  | const TRANSFER_FIELDS = [ | ||||||
|  |   { name: "bus", label: "Автобус" }, | ||||||
|  |   { name: "metro_blue", label: "Метро (синяя)" }, | ||||||
|  |   { name: "metro_green", label: "Метро (зеленая)" }, | ||||||
|  |   { name: "metro_orange", label: "Метро (оранжевая)" }, | ||||||
|  |   { name: "metro_purple", label: "Метро (фиолетовая)" }, | ||||||
|  |   { name: "metro_red", label: "Метро (красная)" }, | ||||||
|  |   { name: "train", label: "Электричка" }, | ||||||
|  |   { name: "tram", label: "Трамвай" }, | ||||||
|  |   { name: "trolleybus", label: "Троллейбус" }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | const style = { | ||||||
|  |   position: "absolute", | ||||||
|  |   top: "50%", | ||||||
|  |   left: "50%", | ||||||
|  |   transform: "translate(-50%, -50%)", | ||||||
|  |   width: "60%", | ||||||
|  |   bgcolor: "background.paper", | ||||||
|  |   border: "2px solid #000", | ||||||
|  |   boxShadow: 24, | ||||||
|  |   p: 4, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const StationEditModal = observer(() => { | ||||||
|  |   const { | ||||||
|  |     stationModalOpen, | ||||||
|  |     setStationModalOpenAction, | ||||||
|  |     selectedStationId, | ||||||
|  |     selectedRouteId, | ||||||
|  |   } = stationStore; | ||||||
|  |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     return () => { | ||||||
|  |       setStationModalOpenAction(false); | ||||||
|  |     }; | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const apiUrl = useApiUrl(); | ||||||
|  |  | ||||||
|  |   const { data: stationQuery, isLoading: isStationLoading } = useCustom({ | ||||||
|  |     url: `${apiUrl}/route/${selectedRouteId ?? 1}/station`, | ||||||
|  |     method: "get", | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { | ||||||
|  |     register, | ||||||
|  |     control, | ||||||
|  |     formState: { errors }, | ||||||
|  |     saveButtonProps, | ||||||
|  |     reset, | ||||||
|  |     setValue, | ||||||
|  |     watch, | ||||||
|  |     handleSubmit, | ||||||
|  |   } = useForm({ | ||||||
|  |     refineCoreProps: { | ||||||
|  |       resource: `route/${selectedRouteId ?? 1}/station`, | ||||||
|  |       action: "edit", | ||||||
|  |       id: "", | ||||||
|  |       redirect: false, | ||||||
|  |       onMutationSuccess: (data) => { | ||||||
|  |         setStationModalOpenAction(false); | ||||||
|  |         reset(); | ||||||
|  |         window.location.reload(); | ||||||
|  |       }, | ||||||
|  |       meta: { | ||||||
|  |         headers: { | ||||||
|  |           "Accept-Language": language, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (stationModalOpen) { | ||||||
|  |       const station = stationQuery?.data?.find( | ||||||
|  |         (station: StationItem) => station.id === selectedStationId | ||||||
|  |       ); | ||||||
|  |       if (!station) return; | ||||||
|  |       for (const key in station) { | ||||||
|  |         setValue(key, station[key]); | ||||||
|  |       } | ||||||
|  |       setValue("station_id", station.id); | ||||||
|  |     } | ||||||
|  |   }, [stationModalOpen, stationQuery]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Modal | ||||||
|  |       open={stationModalOpen} | ||||||
|  |       onClose={() => setStationModalOpenAction(false)} | ||||||
|  |       aria-labelledby="modal-modal-title" | ||||||
|  |       aria-describedby="modal-modal-description" | ||||||
|  |     > | ||||||
|  |       <Box sx={style}> | ||||||
|  |         <Edit | ||||||
|  |           title={<Typography variant="h5">Редактирование остановки</Typography>} | ||||||
|  |           saveButtonProps={saveButtonProps} | ||||||
|  |         > | ||||||
|  |           <Box | ||||||
|  |             component="form" | ||||||
|  |             sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |             autoComplete="off" | ||||||
|  |           > | ||||||
|  |             <TextField | ||||||
|  |               {...register("offset_x", { | ||||||
|  |                 setValueAs: (value) => parseFloat(value), | ||||||
|  |               })} | ||||||
|  |               error={!!(errors as any)?.offset_x} | ||||||
|  |               helperText={(errors as any)?.offset_x?.message} | ||||||
|  |               margin="normal" | ||||||
|  |               fullWidth | ||||||
|  |               InputLabelProps={{ shrink: true }} | ||||||
|  |               type="number" | ||||||
|  |               label={"Смещение (X)"} | ||||||
|  |               name="offset_x" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             <TextField | ||||||
|  |               {...register("offset_y", { | ||||||
|  |                 required: "Это поле является обязательным", | ||||||
|  |                 setValueAs: (value) => parseFloat(value), | ||||||
|  |               })} | ||||||
|  |               error={!!(errors as any)?.offset_y} | ||||||
|  |               helperText={(errors as any)?.offset_y?.message} | ||||||
|  |               margin="normal" | ||||||
|  |               fullWidth | ||||||
|  |               InputLabelProps={{ shrink: true }} | ||||||
|  |               type="number" | ||||||
|  |               label={"Смещение (Y)"} | ||||||
|  |               name="offset_y" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             {/* Группа полей пересадок */} | ||||||
|  |             <Paper sx={{ p: 2, mt: 2 }}> | ||||||
|  |               <Typography variant="h6" gutterBottom> | ||||||
|  |                 Пересадки | ||||||
|  |               </Typography> | ||||||
|  |               <Grid container spacing={2}> | ||||||
|  |                 {TRANSFER_FIELDS.map((field) => ( | ||||||
|  |                   <Grid item xs={12} sm={6} md={4} key={field.name}> | ||||||
|  |                     <TextField | ||||||
|  |                       {...register(`transfers.${field.name}`)} | ||||||
|  |                       error={!!(errors as any)?.transfers?.[field.name]} | ||||||
|  |                       helperText={ | ||||||
|  |                         (errors as any)?.transfers?.[field.name]?.message | ||||||
|  |                       } | ||||||
|  |                       margin="normal" | ||||||
|  |                       fullWidth | ||||||
|  |                       InputLabelProps={{ shrink: true }} | ||||||
|  |                       type="text" | ||||||
|  |                       label={field.label} | ||||||
|  |                       name={`transfers.${field.name}`} | ||||||
|  |                     /> | ||||||
|  |                   </Grid> | ||||||
|  |                 ))} | ||||||
|  |               </Grid> | ||||||
|  |             </Paper> | ||||||
|  |           </Box> | ||||||
|  |         </Edit> | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										71
									
								
								src/components/ui/LanguageSelector.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/components/ui/LanguageSelector.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | |||||||
|  | import { Box } from "@mui/material"; | ||||||
|  | import { Languages, languageStore } from "@stores"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export const LanguageSelector = observer(({ | ||||||
|  |   action | ||||||
|  | }: {action?: (lang: Languages) => void}) => { | ||||||
|  | 	const { language, setLanguageAction } = languageStore; | ||||||
|  |  | ||||||
|  |   function handleLanguageChange(language: Languages) { | ||||||
|  |     if(action) action(language); | ||||||
|  |     else setLanguageAction(language); | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   return ( | ||||||
|  |     <Box | ||||||
|  |       sx={{ | ||||||
|  |         display: "flex", | ||||||
|  |         gap: 2, | ||||||
|  |         height: "min-content" | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <Box | ||||||
|  |         sx={{ | ||||||
|  |           cursor: "pointer", | ||||||
|  |           flex: 1, | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           bgcolor: language === "ru" ? "primary.main" : "transparent", | ||||||
|  |           color: language === "ru" ? "white" : "inherit", | ||||||
|  |           borderRadius: 1, | ||||||
|  |           p: 1, | ||||||
|  |         }} | ||||||
|  |         onClick={() => handleLanguageChange("ru")} | ||||||
|  |       > | ||||||
|  |         RU | ||||||
|  |       </Box> | ||||||
|  |       <Box | ||||||
|  |         sx={{ | ||||||
|  |           cursor: "pointer", | ||||||
|  |           flex: 1, | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           bgcolor: language === "en" ? "primary.main" : "transparent", | ||||||
|  |           color: language === "en" ? "white" : "inherit", | ||||||
|  |           borderRadius: 1, | ||||||
|  |           p: 1, | ||||||
|  |         }} | ||||||
|  |         onClick={() => handleLanguageChange("en")} | ||||||
|  |       > | ||||||
|  |         EN | ||||||
|  |       </Box> | ||||||
|  |       <Box | ||||||
|  |         sx={{ | ||||||
|  |           cursor: "pointer", | ||||||
|  |           flex: 1, | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           bgcolor: language === "zh" ? "primary.main" : "transparent", | ||||||
|  |           color: language === "zh" ? "white" : "inherit", | ||||||
|  |           borderRadius: 1, | ||||||
|  |           p: 1, | ||||||
|  |         }} | ||||||
|  |         onClick={() => handleLanguageChange("zh")} | ||||||
|  |       > | ||||||
|  |         ZH | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										106
									
								
								src/components/ui/MediaView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/components/ui/MediaView.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | |||||||
|  | import { Box } from "@mui/material"; | ||||||
|  | import { TOKEN_KEY } from "@providers"; | ||||||
|  | import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; | ||||||
|  | import { ModelViewer } from "./ModelViewer"; | ||||||
|  |  | ||||||
|  | export interface MediaData { | ||||||
|  |   id: string | number; | ||||||
|  |   media_type: number; | ||||||
|  |   filename?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function MediaView({ media }: Readonly<{ media?: MediaData }>) { | ||||||
|  |   const token = localStorage.getItem(TOKEN_KEY); | ||||||
|  |   return ( | ||||||
|  |     <Box | ||||||
|  |       sx={{ | ||||||
|  |         maxHeight: "300px", | ||||||
|  |         width: "80%", | ||||||
|  |         height: "100%", | ||||||
|  |         maxWidth: "600px", | ||||||
|  |         display: "flex", | ||||||
|  |         flexGrow: 1, | ||||||
|  |         justifyContent: "center", | ||||||
|  |         margin: "0 auto", | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       {media?.media_type === 1 && ( | ||||||
|  |         <img | ||||||
|  |           src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |             media?.id | ||||||
|  |           }/download?token=${token}`} | ||||||
|  |           alt={media?.filename} | ||||||
|  |           style={{ | ||||||
|  |             maxWidth: "100%", | ||||||
|  |             height: "auto", | ||||||
|  |             objectFit: "contain", | ||||||
|  |             borderRadius: 8, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {media?.media_type === 2 && ( | ||||||
|  |         <video | ||||||
|  |           src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |             media?.id | ||||||
|  |           }/download?token=${token}`} | ||||||
|  |           style={{ | ||||||
|  |             maxWidth: "100%", | ||||||
|  |             height: "100%", | ||||||
|  |             objectFit: "contain", | ||||||
|  |             borderRadius: 30, | ||||||
|  |           }} | ||||||
|  |           controls | ||||||
|  |           autoPlay | ||||||
|  |           muted | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |       {media?.media_type === 3 && ( | ||||||
|  |         <img | ||||||
|  |           src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |             media?.id | ||||||
|  |           }/download?token=${token}`} | ||||||
|  |           alt={media?.filename} | ||||||
|  |           style={{ | ||||||
|  |             maxWidth: "100%", | ||||||
|  |             height: "100%", | ||||||
|  |             objectFit: "contain", | ||||||
|  |             borderRadius: 8, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |       {media?.media_type === 4 && ( | ||||||
|  |         <img | ||||||
|  |           src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |             media?.id | ||||||
|  |           }/download?token=${token}`} | ||||||
|  |           alt={media?.filename} | ||||||
|  |           style={{ | ||||||
|  |             maxWidth: "100%", | ||||||
|  |             height: "100%", | ||||||
|  |             objectFit: "contain", | ||||||
|  |             borderRadius: 8, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {media?.media_type === 5 && ( | ||||||
|  |         <ReactPhotoSphereViewer | ||||||
|  |           src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |             media?.id | ||||||
|  |           }/download?token=${token}`} | ||||||
|  |           width={"100%"} | ||||||
|  |           height={"100%"} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {media?.media_type === 6 && ( | ||||||
|  |         <ModelViewer | ||||||
|  |           fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |             media?.id | ||||||
|  |           }/download?token=${token}`} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										22
									
								
								src/components/ui/ModelViewer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/components/ui/ModelViewer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | import { Canvas } from "@react-three/fiber"; | ||||||
|  | import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; | ||||||
|  |  | ||||||
|  | type ModelViewerProps = { | ||||||
|  |   fileUrl: string; | ||||||
|  |   height?: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const ModelViewer = ({ fileUrl, height = "100%" }: ModelViewerProps) => { | ||||||
|  |   const { scene } = useGLTF(fileUrl); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Canvas style={{ width: "100%", height: height }}> | ||||||
|  |       <ambientLight /> | ||||||
|  |       <directionalLight /> | ||||||
|  |       <Stage environment="city" intensity={0.6}> | ||||||
|  |         <primitive object={scene} /> | ||||||
|  |       </Stage> | ||||||
|  |       <OrbitControls /> | ||||||
|  |     </Canvas> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -1,11 +1,15 @@ | |||||||
| import {ProjectIcon} from './Icons' | import { Logo } from "@/icons/Logo"; | ||||||
|  |  | ||||||
| export default function SidebarTitle({collapsed}: {collapsed: boolean}) { | export default function SidebarTitle({ collapsed }: { collapsed: boolean }) { | ||||||
|   return ( |   return ( | ||||||
|     <div style={{display: 'flex', alignItems: 'center', whiteSpace: 'nowrap'}}> |     <div | ||||||
|       <ProjectIcon style={{color: '#7f6b58'}} /> |       style={{ display: "flex", alignItems: "center", whiteSpace: "nowrap" }} | ||||||
|  |     > | ||||||
|  |       <Logo width={40} height={40} /> | ||||||
|  |  | ||||||
|       {!collapsed && <span style={{marginLeft: 8, fontWeight: 'bold'}}>Белые ночи</span>} |       {!collapsed && ( | ||||||
|  |         <span style={{ marginLeft: 8, fontWeight: "bold" }}>Белые ночи</span> | ||||||
|  |       )} | ||||||
|     </div> |     </div> | ||||||
|   ) |   ); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								src/components/ui/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/ui/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | export * from './Icons'; | ||||||
|  | export * from './LanguageSelector'; | ||||||
|  | export * from './SidebarTitle'; | ||||||
|  | export * from './MediaView'; | ||||||
|  | export * from './ModelViewer'; | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| import {createTheme} from '@mui/material/styles' | import {createTheme} from '@mui/material/styles' | ||||||
| import {RefineThemes} from '@refinedev/mui' | import {RefineThemes} from '@refinedev/mui' | ||||||
|  |  | ||||||
| const COLORS = { | export const COLORS = { | ||||||
|   primary: '#7f6b58', |   primary: '#7f6b58', | ||||||
|   secondary: '#48989f', |   secondary: '#48989f', | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| @import './stylesheets/hidden-functionality.css'; | @import "./stylesheets/hidden-functionality.css"; | ||||||
| @import './stylesheets/roles-functionality.css'; | @import "./stylesheets/roles-functionality.css"; | ||||||
|  |  | ||||||
| .limited-text { | .limited-text { | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
| @@ -7,3 +7,19 @@ | |||||||
|   -webkit-box-orient: vertical; |   -webkit-box-orient: vertical; | ||||||
|   -webkit-line-clamp: 2; |   -webkit-line-clamp: 2; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .backup-button { | ||||||
|  |   background-color: transparent; | ||||||
|  |   border: none; | ||||||
|  |   cursor: pointer; | ||||||
|  |   padding: 0; | ||||||
|  |   margin: 0; | ||||||
|  |   width: 32px; | ||||||
|  |   height: 32px; | ||||||
|  |   color: rgba(79, 138, 95, 1); | ||||||
|  |   border-radius: 10%; | ||||||
|  |   transition: all 0.3s ease; | ||||||
|  | } | ||||||
|  | .backup-button:hover { | ||||||
|  |   background-color: rgba(79, 138, 95, 0.05); | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								src/icons/124.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/icons/124.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 7.5 KiB | 
							
								
								
									
										22
									
								
								src/icons/Logo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/icons/Logo.tsx
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -1,14 +1,9 @@ | |||||||
| import React from 'react' | import { createRoot } from "react-dom/client"; | ||||||
| import {createRoot} from 'react-dom/client' |  | ||||||
|  |  | ||||||
| import App from './App' | import App from "./App"; | ||||||
| import './globals.css' | import "./globals.css"; | ||||||
|  |  | ||||||
| const container = document.getElementById('root') as HTMLElement | const container = document.getElementById("root") as HTMLElement; | ||||||
| const root = createRoot(container) | const root = createRoot(container); | ||||||
|  |  | ||||||
| root.render( | root.render(<App />); | ||||||
|   <React.StrictMode> |  | ||||||
|     <App /> |  | ||||||
|   </React.StrictMode>, |  | ||||||
| ) |  | ||||||
|   | |||||||
| @@ -1,11 +1,13 @@ | |||||||
| export const BACKEND_URL = 'https://wn.krbl.ru' |  | ||||||
|  |  | ||||||
| export const MEDIA_TYPES = [ | export const MEDIA_TYPES = [ | ||||||
|   {label: 'Фото', value: 1}, |   { label: "Фото", value: 1 }, | ||||||
|   {label: 'Видео', value: 2}, |   { label: "Видео", value: 2 }, | ||||||
| ] |   { label: "Иконка", value: 3 }, | ||||||
|  |   { label: "Водяной знак", value: 4 }, | ||||||
|  |   { label: "Панорама", value: 5 }, | ||||||
|  |   { label: "3Д-модель", value: 6 }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
| export const VEHICLE_TYPES = [ | export const VEHICLE_TYPES = [ | ||||||
|   {label: 'Трамвай', value: 1}, |   { label: "Трамвай", value: 1 }, | ||||||
|   {label: 'Троллейбус', value: 2}, |   { label: "Троллейбус", value: 2 }, | ||||||
| ] | ]; | ||||||
							
								
								
									
										1
									
								
								src/lib/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/lib/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export { MEDIA_TYPES, VEHICLE_TYPES } from './constants' | ||||||
| @@ -94,11 +94,18 @@ | |||||||
|   }, |   }, | ||||||
|   "station": { |   "station": { | ||||||
|     "titles": { |     "titles": { | ||||||
|       "create": "Создать станцию", |       "create": "Создать остановку", | ||||||
|       "edit": "Редактировать станцию", |       "edit": "Редактировать остановку", | ||||||
|       "show": "Показать станцию" |       "show": "Показать остановку" | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   "snapshots": { | ||||||
|  |     "titles": { | ||||||
|  |       "create": "Создать снапшот", | ||||||
|  |       "show": "Показать снапшот" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |  | ||||||
|   "vehicle": { |   "vehicle": { | ||||||
|     "titles": { |     "titles": { | ||||||
|       "create": "Создать транспорт", |       "create": "Создать транспорт", | ||||||
|   | |||||||
| @@ -1,72 +1,138 @@ | |||||||
| import {Box, TextField, Typography, Paper} from '@mui/material' | import { Box, TextField, Typography, Paper } from "@mui/material"; | ||||||
| import {Create} from '@refinedev/mui' | import { Create } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
| import {Controller} from 'react-hook-form' | import { Controller, FieldValues } from "react-hook-form"; | ||||||
| import React, {useState, useEffect} from 'react' | import React, { useState, useEffect } from "react"; | ||||||
| import ReactMarkdown from 'react-markdown' | import ReactMarkdown from "react-markdown"; | ||||||
|  | import { MarkdownEditor } from "../../components/MarkdownEditor"; | ||||||
|  | import "easymde/dist/easymde.min.css"; | ||||||
|  | import { LanguageSelector } from "@ui"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  | import rehypeRaw from "rehype-raw"; | ||||||
|  |  | ||||||
| import {MarkdownEditor} from '../../components/MarkdownEditor' | const MemoizedSimpleMDE = React.memo(MarkdownEditor); | ||||||
| import 'easymde/dist/easymde.min.css' |  | ||||||
|  |  | ||||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor) | export const ArticleCreate = observer(() => { | ||||||
|  |   const { language, setLanguageAction } = languageStore; | ||||||
|  |   const [articleData, setArticleData] = useState({ | ||||||
|  |     heading: EVERY_LANGUAGE(""), | ||||||
|  |     body: EVERY_LANGUAGE("") | ||||||
|  |   }); | ||||||
|  |  | ||||||
| export const ArticleCreate = () => { |  | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {formLoading}, |     refineCore: { formLoading, onFinish }, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     watch, |     watch, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|  |     setValue, | ||||||
|  |     handleSubmit, | ||||||
|   } = useForm({ |   } = useForm({ | ||||||
|     refineCoreProps: { |     refineCoreProps: { | ||||||
|       resource: 'article/', |       resource: "article", | ||||||
|     }, |       ...META_LANGUAGE(language) | ||||||
|   }) |     } | ||||||
|  |   }); | ||||||
|   const [preview, setPreview] = useState('') |  | ||||||
|   const [headingPreview, setHeadingPreview] = useState('') |  | ||||||
|  |  | ||||||
|   // Следим за изменениями в полях body и heading |   // Следим за изменениями в полях body и heading | ||||||
|   const bodyContent = watch('body') |   const bodyContent = watch("body"); | ||||||
|   const headingContent = watch('heading') |   const headingContent = watch("heading"); | ||||||
|  |  | ||||||
|  |   function updateTranslations(update: boolean = true) { | ||||||
|  |     const newArticleData = { | ||||||
|  |       ...articleData, | ||||||
|  |       heading: { | ||||||
|  |         ...articleData.heading, | ||||||
|  |         [language]: watch("heading") ?? "", | ||||||
|  |       }, | ||||||
|  |       body: { | ||||||
|  |         ...articleData.body, | ||||||
|  |         [language]: watch("body") ?? "", | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if(update) setArticleData(newArticleData); | ||||||
|  |     return newArticleData; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const handleFormSubmit = handleSubmit((values) => { | ||||||
|  |     const newTranslations = updateTranslations(false); | ||||||
|  |     return onFinish({ | ||||||
|  |       translations: newTranslations | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setPreview(bodyContent || '') |     setValue("heading", articleData.heading[language] ?? ""); | ||||||
|   }, [bodyContent]) |     setValue("body", articleData.body[language] ?? ""); | ||||||
|  |     setPreview(articleData.body[language] ?? ""); | ||||||
|  |     setHeadingPreview(articleData.heading[language] ?? ""); | ||||||
|  |   }, [language, articleData, setValue]); | ||||||
|  |  | ||||||
|  |   const handleLanguageChange = (lang: Languages) => { | ||||||
|  |     updateTranslations(); | ||||||
|  |     setLanguageAction(lang); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const [preview, setPreview] = useState(""); | ||||||
|  |   const [headingPreview, setHeadingPreview] = useState(""); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setHeadingPreview(headingContent || '') |     setPreview(bodyContent ?? ""); | ||||||
|   }, [headingContent]) |   }, [bodyContent]); | ||||||
|  |  | ||||||
|   const simpleMDEOptions = React.useMemo( |   useEffect(() => { | ||||||
|     () => ({ |     setHeadingPreview(headingContent ?? ""); | ||||||
|       placeholder: 'Введите контент в формате Markdown...', |   }, [headingContent]); | ||||||
|       spellChecker: false, |  | ||||||
|     }), |   const simpleMDEOptions = React.useMemo(() => ({ | ||||||
|     [], |     placeholder: "Введите контент в формате Markdown...", | ||||||
|   ) |     spellChecker: false, | ||||||
|  |   }), []); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> |     <Create isLoading={formLoading} saveButtonProps={{ | ||||||
|       <Box sx={{display: 'flex', gap: 2}}> |       onClick: handleFormSubmit | ||||||
|  |     }}> | ||||||
|  |       <Box sx={{ display: "flex", flex: 1, gap: 2 }}> | ||||||
|         {/* Форма создания */} |         {/* Форма создания */} | ||||||
|         <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> |         <Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}> | ||||||
|           <TextField |           <LanguageSelector action={handleLanguageChange} /> | ||||||
|             {...register('heading', { |           <Box | ||||||
|               required: 'Это поле является обязательным', |             component="form" | ||||||
|             })} |             sx={{ flex: 1, display: "flex", flexDirection: "column" }} | ||||||
|             error={!!(errors as any)?.heading} |             autoComplete="off" | ||||||
|             helperText={(errors as any)?.heading?.message} |           > | ||||||
|             margin="normal" |             <TextField | ||||||
|             fullWidth |               {...register("heading", { | ||||||
|             InputLabelProps={{shrink: true}} |                 required: "Это поле является обязательным", | ||||||
|             type="text" |               })} | ||||||
|             label="Заголовок *" |               error={!!(errors as any)?.heading} | ||||||
|             name="heading" |               helperText={(errors as any)?.heading?.message} | ||||||
|           /> |               margin="normal" | ||||||
|  |               fullWidth | ||||||
|  |               slotProps={{inputLabel: {shrink: true}}} | ||||||
|  |               type="text" | ||||||
|  |               label="Заголовок *" | ||||||
|  |               name="heading" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|           <Controller control={control} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} /> |             <Controller | ||||||
|  |               control={control} | ||||||
|  |               name="body" | ||||||
|  |               //rules={{ required: "Это поле является обязательным" }} | ||||||
|  |               defaultValue="" | ||||||
|  |               render={({ field: { onChange, value } }) => ( | ||||||
|  |                 <MemoizedSimpleMDE | ||||||
|  |                   value={value} | ||||||
|  |                   onChange={onChange} | ||||||
|  |                   options={simpleMDEOptions} | ||||||
|  |                   className="my-markdown-editor" | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|  |             /> | ||||||
|  |           </Box> | ||||||
|         </Box> |         </Box> | ||||||
|  |  | ||||||
|         {/* Блок предпросмотра */} |         {/* Блок предпросмотра */} | ||||||
| @@ -74,14 +140,15 @@ export const ArticleCreate = () => { | |||||||
|           sx={{ |           sx={{ | ||||||
|             flex: 1, |             flex: 1, | ||||||
|             p: 2, |             p: 2, | ||||||
|             maxHeight: 'calc(100vh - 200px)', |             maxHeight: "calc(100vh - 200px)", | ||||||
|             overflowY: 'auto', |             overflowY: "auto", | ||||||
|             position: 'sticky', |             position: "sticky", | ||||||
|             top: 16, |             top: 16, | ||||||
|             borderRadius: 2, |             borderRadius: 2, | ||||||
|             border: '1px solid', |             border: "1px solid", | ||||||
|             borderColor: 'primary.main', |             borderColor: "primary.main", | ||||||
|             bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), |             bgcolor: (theme) => | ||||||
|  |               theme.palette.mode === "dark" ? "background.paper" : "#fff", | ||||||
|           }} |           }} | ||||||
|         > |         > | ||||||
|           <Typography variant="h6" gutterBottom color="primary"> |           <Typography variant="h6" gutterBottom color="primary"> | ||||||
| @@ -93,7 +160,8 @@ export const ArticleCreate = () => { | |||||||
|             variant="h4" |             variant="h4" | ||||||
|             gutterBottom |             gutterBottom | ||||||
|             sx={{ |             sx={{ | ||||||
|               color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), |               color: (theme) => | ||||||
|  |                 theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||||
|               mb: 3, |               mb: 3, | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
| @@ -103,46 +171,48 @@ export const ArticleCreate = () => { | |||||||
|           {/* Markdown контент */} |           {/* Markdown контент */} | ||||||
|           <Box |           <Box | ||||||
|             sx={{ |             sx={{ | ||||||
|               '& img': { |               "& img": { | ||||||
|                 maxWidth: '100%', |                 maxWidth: "100%", | ||||||
|                 height: 'auto', |                 height: "auto", | ||||||
|                 borderRadius: 1, |                 borderRadius: 1, | ||||||
|               }, |               }, | ||||||
|               '& h1, & h2, & h3, & h4, & h5, & h6': { |               "& h1, & h2, & h3, & h4, & h5, & h6": { | ||||||
|                 color: 'primary.main', |                 color: "primary.main", | ||||||
|                 mt: 2, |                 mt: 2, | ||||||
|                 mb: 1, |                 mb: 1, | ||||||
|               }, |               }, | ||||||
|               '& p': { |               "& p": { | ||||||
|                 mb: 2, |                 mb: 2, | ||||||
|                 color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), |                 color: (theme) => | ||||||
|  |                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||||
|               }, |               }, | ||||||
|               '& a': { |               "& a": { | ||||||
|                 color: 'primary.main', |                 color: "primary.main", | ||||||
|                 textDecoration: 'none', |                 textDecoration: "none", | ||||||
|                 '&:hover': { |                 "&:hover": { | ||||||
|                   textDecoration: 'underline', |                   textDecoration: "underline", | ||||||
|                 }, |                 }, | ||||||
|               }, |               }, | ||||||
|               '& blockquote': { |               "& blockquote": { | ||||||
|                 borderLeft: '4px solid', |                 borderLeft: "4px solid", | ||||||
|                 borderColor: 'primary.main', |                 borderColor: "primary.main", | ||||||
|                 pl: 2, |                 pl: 2, | ||||||
|                 my: 2, |                 my: 2, | ||||||
|                 color: 'text.secondary', |                 color: "text.secondary", | ||||||
|               }, |               }, | ||||||
|               '& code': { |               "& code": { | ||||||
|                 bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'), |                 bgcolor: (theme) => | ||||||
|  |                   theme.palette.mode === "dark" ? "grey.900" : "grey.100", | ||||||
|                 p: 0.5, |                 p: 0.5, | ||||||
|                 borderRadius: 0.5, |                 borderRadius: 0.5, | ||||||
|                 color: 'primary.main', |                 color: "primary.main", | ||||||
|               }, |               }, | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             <ReactMarkdown>{preview}</ReactMarkdown> |             <ReactMarkdown rehypePlugins={[rehypeRaw]}>{preview}</ReactMarkdown> | ||||||
|           </Box> |           </Box> | ||||||
|         </Paper> |         </Paper> | ||||||
|       </Box> |       </Box> | ||||||
|     </Create> |     </Create> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,96 +1,167 @@ | |||||||
| import {Box, TextField, Typography, Paper} from '@mui/material' | import { Box, TextField, Typography, Paper } from "@mui/material"; | ||||||
| import {Edit} from '@refinedev/mui' | import { Edit } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
| import {Controller} from 'react-hook-form' | import { Controller, FieldValues } from "react-hook-form"; | ||||||
| import {useParams} from 'react-router' | import { useParams } from "react-router"; | ||||||
| import React, {useState, useEffect} from 'react' | import React, { useState, useEffect, useMemo } from "react"; | ||||||
| import ReactMarkdown from 'react-markdown' | import ReactMarkdown from "react-markdown"; | ||||||
| import {useList} from '@refinedev/core' | import { useList } from "@refinedev/core"; | ||||||
|  |  | ||||||
| import {MarkdownEditor} from '../../components/MarkdownEditor' | import { MarkdownEditor, LinkedItems } from "@components"; | ||||||
| import {LinkedItems} from '../../components/LinkedItems' | import { MediaItem, mediaFields } from "./types"; | ||||||
| import {MediaItem, mediaFields} from './types' | import "easymde/dist/easymde.min.css"; | ||||||
| import {TOKEN_KEY} from '../../authProvider' | import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from "@stores"; | ||||||
| import 'easymde/dist/easymde.min.css' | import { observer } from "mobx-react-lite"; | ||||||
|  | import { LanguageSelector, MediaView } from "@ui"; | ||||||
|  | import rehypeRaw from "rehype-raw"; | ||||||
|  |  | ||||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor) | const MemoizedSimpleMDE = React.memo(MarkdownEditor); | ||||||
|  |  | ||||||
|  | export const ArticleEdit = observer(() => { | ||||||
|  |   const { language, setLanguageAction } = languageStore; | ||||||
|  |  | ||||||
|  |   const [articleData, setArticleData] = useState({ | ||||||
|  |     heading: EVERY_LANGUAGE(""), | ||||||
|  |     body: EVERY_LANGUAGE("") | ||||||
|  |   }); | ||||||
|  |   const { id: articleId } = useParams<{ id: string }>(); | ||||||
|  |   const simpleMDEOptions = useMemo( | ||||||
|  |     () => ({ | ||||||
|  |       placeholder: "Введите контент в формате Markdown...", | ||||||
|  |       spellChecker: false, | ||||||
|  |     }), | ||||||
|  |     [] | ||||||
|  |   ); | ||||||
|  |  | ||||||
| export const ArticleEdit = () => { |  | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|  |     refineCore: { onFinish }, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|  |     handleSubmit, | ||||||
|     watch, |     watch, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|   } = useForm() |     setValue, | ||||||
|  |     getValues, | ||||||
|  |   } = useForm<{ heading: string; body: string }>({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {id: articleId} = useParams<{id: string}>() |   const bodyContent = watch("body"); | ||||||
|   const [preview, setPreview] = useState('') |   const headingContent = watch("heading"); | ||||||
|   const [headingPreview, setHeadingPreview] = useState('') |  | ||||||
|  |  | ||||||
|   // Получаем привязанные медиа |  | ||||||
|   const {data: mediaData} = useList<MediaItem>({ |  | ||||||
|     resource: `article/${articleId}/media`, |  | ||||||
|     queryOptions: { |  | ||||||
|       enabled: !!articleId, |  | ||||||
|     }, |  | ||||||
|   }) |  | ||||||
|  |  | ||||||
|   // Следим за изменениями в полях body и heading |  | ||||||
|   const bodyContent = watch('body') |  | ||||||
|   const headingContent = watch('heading') |  | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setPreview(bodyContent || '') |     console.log(bodyContent) | ||||||
|   }, [bodyContent]) |   }, [bodyContent]) | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setHeadingPreview(headingContent || '') |     console.log(articleData) | ||||||
|   }, [headingContent]) |   }, [articleData]) | ||||||
|  |  | ||||||
|   const simpleMDEOptions = React.useMemo( |   useEffect(() => { | ||||||
|     () => ({ |     console.log("Trying to udpate") | ||||||
|       placeholder: 'Введите контент в формате Markdown...', |     //setHeadingPreview(articleData.heading[language] ?? ""); | ||||||
|       spellChecker: false, |     //setPreview(articleData.body[language] ?? ""); | ||||||
|     }), |     if(articleData.heading[language]) | ||||||
|     [], |       setValue("heading", articleData.heading[language]); | ||||||
|   ) |     if(articleData.body[language]) | ||||||
|  |       setValue("body", articleData.body[language]); | ||||||
|  |   }, [language]); | ||||||
|  |  | ||||||
|  |   function updateTranslations(update: boolean = true) { | ||||||
|  |     const newArticleData = { | ||||||
|  |       heading: { | ||||||
|  |         ...articleData.heading, | ||||||
|  |         [language]: watch("heading") ?? "", | ||||||
|  |       }, | ||||||
|  |       body: { | ||||||
|  |         ...articleData.body, | ||||||
|  |         [language]: watch("body") ?? "", | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if(update) setArticleData(newArticleData); | ||||||
|  |     return newArticleData; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const handleLanguageChange = (lang: Languages) => { | ||||||
|  |     updateTranslations(); | ||||||
|  |     setLanguageAction(lang); | ||||||
|  |     console.log("Setting preview to", articleData.body[lang] ?? "") | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleFormSubmit = handleSubmit((values: FieldValues) => { | ||||||
|  |     return onFinish({ | ||||||
|  |       translations: updateTranslations(false) | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { data: mediaData, refetch } = useList<MediaItem>({ | ||||||
|  |     resource: `article/${articleId}/media`, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     return () => { | ||||||
|  |       setLanguageAction("ru"); | ||||||
|  |     }; | ||||||
|  |   }, [setLanguageAction]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={{ | ||||||
|       <Box sx={{display: 'flex', gap: 2}}> |       ...saveButtonProps, | ||||||
|  |       onClick: handleFormSubmit | ||||||
|  |     }} | ||||||
|  |     > | ||||||
|  |       <Box sx={{ display: "flex", gap: 2 }}> | ||||||
|         {/* Форма редактирования */} |         {/* Форма редактирования */} | ||||||
|         <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> |         <Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}> | ||||||
|           <TextField |  | ||||||
|             {...register('heading', { |  | ||||||
|               required: 'Это поле является обязательным', |  | ||||||
|             })} |  | ||||||
|             error={!!(errors as any)?.heading} |  | ||||||
|             helperText={(errors as any)?.heading?.message} |  | ||||||
|             margin="normal" |  | ||||||
|             fullWidth |  | ||||||
|             InputLabelProps={{shrink: true}} |  | ||||||
|             type="text" |  | ||||||
|             label="Заголовок *" |  | ||||||
|             name="heading" |  | ||||||
|           /> |  | ||||||
|            |            | ||||||
|           <Controller |           <LanguageSelector action={handleLanguageChange} /> | ||||||
|             control={control} |           <Box | ||||||
|             name="body" |             component="form" | ||||||
|             rules={{required: 'Это поле является обязательным'}} |             sx={{ flex: 1, display: "flex", flexDirection: "column" }} | ||||||
|             defaultValue="" |             autoComplete="off" | ||||||
|             render={({field: {onChange, value}}) => ( |           > | ||||||
|               <MemoizedSimpleMDE |             <TextField | ||||||
|                 value={value} // markdown |               {...register("heading", { | ||||||
|                 onChange={onChange} |                 required: "Это поле является обязательным", | ||||||
|                 options={simpleMDEOptions} |               })} | ||||||
|                 className="my-markdown-editor" |               error={!!errors?.heading} | ||||||
|  |               helperText={errors?.heading?.message as string} | ||||||
|  |               margin="normal" | ||||||
|  |               fullWidth | ||||||
|  |               slotProps={{inputLabel: {shrink: true}}} | ||||||
|  |               type="text" | ||||||
|  |               label="Заголовок *" | ||||||
|  |               name="heading" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             <Controller | ||||||
|  |               control={control} | ||||||
|  |               name="body" | ||||||
|  |               //rules={{ required: "Это поле является обязательным" }} | ||||||
|  |               defaultValue="" | ||||||
|  |               render={({ field: { onChange, value } }) => ( | ||||||
|  |                 <MemoizedSimpleMDE | ||||||
|  |                   value={value} // markdown | ||||||
|  |                   onChange={onChange} | ||||||
|  |                   options={simpleMDEOptions} | ||||||
|  |                   className="my-markdown-editor" | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |             {articleId && ( | ||||||
|  |               <LinkedItems<MediaItem> | ||||||
|  |                 type="edit" | ||||||
|  |                 parentId={articleId} | ||||||
|  |                 parentResource="article" | ||||||
|  |                 childResource="media" | ||||||
|  |                 fields={mediaFields} | ||||||
|  |                 title="медиа" | ||||||
|  |                 onUpdate={refetch} | ||||||
|               /> |               /> | ||||||
|             )} |             )} | ||||||
|           /> |           </Box> | ||||||
|  |  | ||||||
|           {articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />} |  | ||||||
|         </Box> |         </Box> | ||||||
|  |  | ||||||
|         {/* Блок предпросмотра */} |         {/* Блок предпросмотра */} | ||||||
| @@ -98,14 +169,15 @@ export const ArticleEdit = () => { | |||||||
|           sx={{ |           sx={{ | ||||||
|             flex: 1, |             flex: 1, | ||||||
|             p: 2, |             p: 2, | ||||||
|             maxHeight: 'calc(100vh - 200px)', |             maxHeight: "calc(100vh - 200px)", | ||||||
|             overflowY: 'auto', |             overflowY: "auto", | ||||||
|             position: 'sticky', |             position: "sticky", | ||||||
|             top: 16, |             top: 16, | ||||||
|             borderRadius: 2, |             borderRadius: 2, | ||||||
|             border: '1px solid', |             border: "1px solid", | ||||||
|             borderColor: 'primary.main', |             borderColor: "primary.main", | ||||||
|             bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), |             bgcolor: (theme) => | ||||||
|  |               theme.palette.mode === "dark" ? "background.paper" : "#fff", | ||||||
|           }} |           }} | ||||||
|         > |         > | ||||||
|           <Typography variant="h6" gutterBottom color="primary"> |           <Typography variant="h6" gutterBottom color="primary"> | ||||||
| @@ -117,66 +189,69 @@ export const ArticleEdit = () => { | |||||||
|             variant="h4" |             variant="h4" | ||||||
|             gutterBottom |             gutterBottom | ||||||
|             sx={{ |             sx={{ | ||||||
|               color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), |               color: (theme) => | ||||||
|  |                 theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||||
|               mb: 3, |               mb: 3, | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             {headingPreview} |             {headingContent} | ||||||
|           </Typography> |           </Typography> | ||||||
|  |  | ||||||
|           {/* Markdown контент */} |           {/* Markdown контент */} | ||||||
|           <Box |           <Box | ||||||
|             sx={{ |             sx={{ | ||||||
|               '& img': { |               "& img": { | ||||||
|                 maxWidth: '100%', |                 maxWidth: "100%", | ||||||
|                 height: 'auto', |                 height: "auto", | ||||||
|                 borderRadius: 1, |                 borderRadius: 1, | ||||||
|               }, |               }, | ||||||
|               '& h1, & h2, & h3, & h4, & h5, & h6': { |               "& h1, & h2, & h3, & h4, & h5, & h6": { | ||||||
|                 color: 'primary.main', |                 color: "primary.main", | ||||||
|                 mt: 2, |                 mt: 2, | ||||||
|                 mb: 1, |                 mb: 1, | ||||||
|               }, |               }, | ||||||
|               '& p': { |               "& p": { | ||||||
|                 mb: 2, |                 mb: 2, | ||||||
|                 color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), |                 color: (theme) => | ||||||
|  |                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||||
|               }, |               }, | ||||||
|               '& a': { |               "& a": { | ||||||
|                 color: 'primary.main', |                 color: "primary.main", | ||||||
|                 textDecoration: 'none', |                 textDecoration: "none", | ||||||
|                 '&:hover': { |                 "&:hover": { | ||||||
|                   textDecoration: 'underline', |                   textDecoration: "underline", | ||||||
|                 }, |                 }, | ||||||
|               }, |               }, | ||||||
|               '& blockquote': { |               "& blockquote": { | ||||||
|                 borderLeft: '4px solid', |                 borderLeft: "4px solid", | ||||||
|                 borderColor: 'primary.main', |                 borderColor: "primary.main", | ||||||
|                 pl: 2, |                 pl: 2, | ||||||
|                 my: 2, |                 my: 2, | ||||||
|                 color: 'text.secondary', |                 color: "text.secondary", | ||||||
|               }, |               }, | ||||||
|               '& code': { |               "& code": { | ||||||
|                 bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'), |                 bgcolor: (theme) => | ||||||
|  |                   theme.palette.mode === "dark" ? "grey.900" : "grey.100", | ||||||
|                 p: 0.5, |                 p: 0.5, | ||||||
|                 borderRadius: 0.5, |                 borderRadius: 0.5, | ||||||
|                 color: 'primary.main', |                 color: "primary.main", | ||||||
|               }, |               }, | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             <ReactMarkdown>{preview}</ReactMarkdown> |             <ReactMarkdown rehypePlugins={[rehypeRaw]}>{bodyContent}</ReactMarkdown> | ||||||
|           </Box> |           </Box> | ||||||
|  |  | ||||||
|           {/* Привязанные медиа */} |           {/* Привязанные медиа */} | ||||||
|           {mediaData?.data && mediaData.data.length > 0 && ( |           {mediaData?.data && mediaData.data.length > 0 && ( | ||||||
|             <Box sx={{mb: 3}}> |             <Box sx={{ mb: 3 }}> | ||||||
|               <Typography variant="subtitle1" gutterBottom color="primary"> |               <Typography variant="subtitle1" gutterBottom color="primary"> | ||||||
|                 Привязанные медиа: |                 Привязанные медиа: | ||||||
|               </Typography> |               </Typography> | ||||||
|               <Box |               <Box | ||||||
|                 sx={{ |                 sx={{ | ||||||
|                   display: 'flex', |                   display: "flex", | ||||||
|                   gap: 1, |                   gap: 1, | ||||||
|                   flexWrap: 'wrap', |                   flexWrap: "wrap", | ||||||
|                   mb: 2, |                   mb: 2, | ||||||
|                 }} |                 }} | ||||||
|               > |               > | ||||||
| @@ -184,23 +259,28 @@ export const ArticleEdit = () => { | |||||||
|                   <Box |                   <Box | ||||||
|                     key={media.id} |                     key={media.id} | ||||||
|                     sx={{ |                     sx={{ | ||||||
|                       width: 120, |                       display: "flex", | ||||||
|                       height: 120, |                       width: "45%", | ||||||
|  |                       height: "45%", | ||||||
|  |                       aspectRatio: "1/1", | ||||||
|                       borderRadius: 1, |                       borderRadius: 1, | ||||||
|                       overflow: 'hidden', |                       overflow: "hidden", | ||||||
|                       border: '1px solid', |                       border: "1px solid", | ||||||
|                       borderColor: 'primary.main', |                       borderColor: "primary.main", | ||||||
|                     }} |                     }} | ||||||
|                   > |                   > | ||||||
|                     <img |                     <MediaView media={media} /> | ||||||
|                       src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`} |                     {/* <img | ||||||
|  |                       src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |                         media.id | ||||||
|  |                       }/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||||
|                       alt={media.media_name} |                       alt={media.media_name} | ||||||
|                       style={{ |                       style={{ | ||||||
|                         width: '100%', |                         width: "100%", | ||||||
|                         height: '100%', |                         height: "100%", | ||||||
|                         objectFit: 'cover', |                         objectFit: "cover", | ||||||
|                       }} |                       }} | ||||||
|                     /> |                     /> */} | ||||||
|                   </Box> |                   </Box> | ||||||
|                 ))} |                 ))} | ||||||
|               </Box> |               </Box> | ||||||
| @@ -209,5 +289,5 @@ export const ArticleEdit = () => { | |||||||
|         </Paper> |         </Paper> | ||||||
|       </Box> |       </Box> | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,37 +1,58 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import React from 'react' |   DeleteButton, | ||||||
|  |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| import {localeText} from '../../locales/ru/localeText' | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  |  | ||||||
| export const ArticleList = () => { | export const ArticleList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({ |   const { language } = languageStore; | ||||||
|     resource: 'article/', |  | ||||||
|   }) |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "article", | ||||||
|  |     ...META_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 70, |         minWidth: 70, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'heading', |         field: "service_name", | ||||||
|         headerName: 'Заголовок', |         headerName: "Заголовок", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 300, |         minWidth: 300, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       // { |       // { | ||||||
|  |       //   field: "service_name", | ||||||
|  |       //   headerName: "Сервисное название (тест)", | ||||||
|  |       //   type: "string", | ||||||
|  |       //   minWidth: 300, | ||||||
|  |       //   display: "flex", | ||||||
|  |       //   align: "left", | ||||||
|  |       //   headerAlign: "left", | ||||||
|  |       //   flex: 1, | ||||||
|  |       // }, | ||||||
|  |       // { | ||||||
|       //   field: 'body', |       //   field: 'body', | ||||||
|       //   headerName: 'Контент', |       //   headerName: 'Контент', | ||||||
|       //   type: 'string', |       //   type: 'string', | ||||||
| @@ -41,32 +62,38 @@ export const ArticleList = () => { | |||||||
|       //   flex: 1, |       //   flex: 1, | ||||||
|       // }, |       // }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "actions", | ||||||
|         headerName: 'Действия', |         headerName: "Действия", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText recordItemId={row.id} /> |               <DeleteButton hideText recordItemId={row.id} /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} /> |       <CustomDataGrid | ||||||
|  |         {...dataGridProps} | ||||||
|  |         languageEnabled | ||||||
|  |         columns={columns} | ||||||
|  |         localeText={localeText} | ||||||
|  |         getRowId={(row: any) => row.id} | ||||||
|  |       /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ export type MediaItem = { | |||||||
|   id: number |   id: number | ||||||
|   filename: string |   filename: string | ||||||
|   media_name: string |   media_name: string | ||||||
|   media_type: string |   media_type: number | ||||||
|   media_order?: number |   media_order?: number | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,172 +1,245 @@ | |||||||
| import {Autocomplete, Box, TextField} from '@mui/material' | import { Autocomplete, Box, TextField } from "@mui/material"; | ||||||
| import {Create, useAutocomplete} from '@refinedev/mui' | import { Create, useAutocomplete } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
| import {Controller} from 'react-hook-form' | import { Controller } from "react-hook-form"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { META_LANGUAGE } from "../../store/LanguageStore"; | ||||||
|  | import { LanguageSwitch } from "@/components/LanguageSwitch"; | ||||||
|  |  | ||||||
| export const CarrierCreate = () => { | import { useEffect, useState } from "react"; | ||||||
|  | import { EVERY_LANGUAGE, Languages, languageStore } from "@stores"; | ||||||
|  |  | ||||||
|  | export const CarrierCreate = observer(() => { | ||||||
|  |   const { language, setLanguageAction } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {formLoading}, |     refineCore: { formLoading }, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     setValue, | ||||||
|   } = useForm({}) |     watch, | ||||||
|  |     formState: { errors }, | ||||||
|  |   } = useForm({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ |   const [carrierData, setCarrierData] = useState({ | ||||||
|     resource: 'city', |     full_name: EVERY_LANGUAGE(""), | ||||||
|  |     short_name: EVERY_LANGUAGE(""), | ||||||
|  |     slogan: EVERY_LANGUAGE(""), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setValue("full_name", carrierData.full_name[language]); | ||||||
|  |     setValue("short_name", carrierData.short_name[language]); | ||||||
|  |     setValue("slogan", carrierData.slogan[language]); | ||||||
|  |   }, [carrierData, language, setValue]); | ||||||
|  |  | ||||||
|  |   function updateTranslations(update: boolean = true) { | ||||||
|  |     const newCarrierData = { | ||||||
|  |       ...carrierData, | ||||||
|  |       full_name: { | ||||||
|  |         ...carrierData.full_name, | ||||||
|  |         [language]: watch("full_name") ?? "", | ||||||
|  |       }, | ||||||
|  |       short_name: { | ||||||
|  |         ...carrierData.short_name, | ||||||
|  |         [language]: watch("short_name") ?? "", | ||||||
|  |       }, | ||||||
|  |       slogan: { | ||||||
|  |         ...carrierData.slogan, | ||||||
|  |         [language]: watch("slogan") ?? "", | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |     if (update) setCarrierData(newCarrierData); | ||||||
|  |     return newCarrierData; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const handleLanguageChange = (lang: Languages) => { | ||||||
|  |     updateTranslations(); | ||||||
|  |     setLanguageAction(lang); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "city", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ |   const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({ | ||||||
|     resource: 'media', |     resource: "media", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'media_name', |         field: "media_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> |     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|  |         <LanguageSwitch action={handleLanguageChange} /> | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="city_id" |           name="city_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...cityAutocompleteProps} |               {...cityAutocompleteProps} | ||||||
|               value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 cityAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) ?? null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id ?? ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.name : '' |                 return item ? item.name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите город" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.city_id} | ||||||
|  |                   helperText={(errors as any)?.city_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('full_name', { |           {...register("full_name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.full_name} |           error={!!(errors as any)?.full_name} | ||||||
|           helperText={(errors as any)?.full_name?.message} |           helperText={(errors as any)?.full_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Полное имя *'} |           label={"Полное имя *"} | ||||||
|           name="full_name" |           name="full_name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('short_name', { |           {...register("short_name", { | ||||||
|             required: 'Это поле является обязательным', |             //required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.short_name} |           error={!!(errors as any)?.short_name} | ||||||
|           helperText={(errors as any)?.short_name?.message} |           helperText={(errors as any)?.short_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Короткое имя *'} |           label={"Короткое имя"} | ||||||
|           name="short_name" |           name="short_name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <Box component="form" sx={{ display: "flex" }} autoComplete="off"> | ||||||
|           {...register('main_color', { |           <TextField | ||||||
|             // required: 'Это поле является обязательным', |             {...register("main_color", { | ||||||
|           })} |               // required: 'Это поле является обязательным', | ||||||
|           error={!!(errors as any)?.main_color} |             })} | ||||||
|           helperText={(errors as any)?.main_color?.message} |             error={!!(errors as any)?.main_color} | ||||||
|           margin="normal" |             helperText={(errors as any)?.main_color?.message} | ||||||
|           fullWidth |             margin="normal" | ||||||
|           InputLabelProps={{shrink: true}} |             fullWidth | ||||||
|           type="color" |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           label={'Основной цвет'} |             type="color" | ||||||
|           name="main_color" |             label={"Основной цвет"} | ||||||
|           sx={{ |             name="main_color" | ||||||
|             '& input': { |             sx={{ | ||||||
|               height: '50px', |               "& input": { | ||||||
|               paddingBlock: '14px', |                 height: "50px", | ||||||
|               paddingInline: '14px', |                 paddingBlock: "14px", | ||||||
|               cursor: 'pointer', |                 paddingInline: "14px", | ||||||
|             }, |                 cursor: "pointer", | ||||||
|           }} |               }, | ||||||
|         /> |             }} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("left_color", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.left_color} | ||||||
|  |             helperText={(errors as any)?.left_color?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="color" | ||||||
|  |             label={"Цвет левого виджета"} | ||||||
|  |             name="left_color" | ||||||
|  |             sx={{ | ||||||
|  |               marginLeft: "16px", | ||||||
|  |               marginRight: "16px", | ||||||
|  |               "& input": { | ||||||
|  |                 height: "50px", | ||||||
|  |                 paddingBlock: "14px", | ||||||
|  |                 paddingInline: "14px", | ||||||
|  |                 cursor: "pointer", | ||||||
|  |               }, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |           <TextField | ||||||
|  |             {...register("right_color", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.right_color} | ||||||
|  |             helperText={(errors as any)?.right_color?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="color" | ||||||
|  |             label={"Цвет правого виджета"} | ||||||
|  |             name="right_color" | ||||||
|  |             sx={{ | ||||||
|  |               "& input": { | ||||||
|  |                 height: "50px", | ||||||
|  |                 paddingBlock: "14px", | ||||||
|  |                 paddingInline: "14px", | ||||||
|  |                 cursor: "pointer", | ||||||
|  |               }, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         </Box> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('left_color', { |           {...register("slogan", { | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.left_color} |  | ||||||
|           helperText={(errors as any)?.left_color?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="color" |  | ||||||
|           label={'Цвет левого виджета'} |  | ||||||
|           name="left_color" |  | ||||||
|           sx={{ |  | ||||||
|             '& input': { |  | ||||||
|               height: '50px', |  | ||||||
|               paddingBlock: '14px', |  | ||||||
|               paddingInline: '14px', |  | ||||||
|               cursor: 'pointer', |  | ||||||
|             }, |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|         <TextField |  | ||||||
|           {...register('right_color', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.right_color} |  | ||||||
|           helperText={(errors as any)?.right_color?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="color" |  | ||||||
|           label={'Цвет правого виджета'} |  | ||||||
|           name="right_color" |  | ||||||
|           sx={{ |  | ||||||
|             '& input': { |  | ||||||
|               height: '50px', |  | ||||||
|               paddingBlock: '14px', |  | ||||||
|               paddingInline: '14px', |  | ||||||
|               cursor: 'pointer', |  | ||||||
|             }, |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('slogan', { |  | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.slogan} |           error={!!(errors as any)?.slogan} | ||||||
|           helperText={(errors as any)?.slogan?.message} |           helperText={(errors as any)?.slogan?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Слоган'} |           label={"Слоган"} | ||||||
|           name="slogan" |           name="slogan" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
| @@ -175,27 +248,44 @@ export const CarrierCreate = () => { | |||||||
|           name="logo" |           name="logo" | ||||||
|           // rules={{required: 'Это поле является обязательным'}} |           // rules={{required: 'Это поле является обязательным'}} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...mediaAutocompleteProps} |               {...mediaAutocompleteProps} | ||||||
|               value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 mediaAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) ?? null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id ?? ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.media_name : '' |                 return item ? item.media_name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.media_name | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите логотип" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.logo} | ||||||
|  |                   helperText={(errors as any)?.logo?.message} | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|     </Create> |     </Create> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,171 +1,208 @@ | |||||||
| import {Autocomplete, Box, TextField} from '@mui/material' | import { Autocomplete, Box, TextField } from "@mui/material"; | ||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' | import { Edit, useAutocomplete } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
| import {Controller} from 'react-hook-form' | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  | import { LanguageSelector, MediaView } from "@ui"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| export const CarrierEdit = () => { | export const CarrierEdit = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     watch, | ||||||
|   } = useForm() |     formState: { errors }, | ||||||
|  |   } = useForm({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ |   const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ | ||||||
|     resource: 'city', |     resource: `city`, | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |     ...META_LANGUAGE("ru") | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ |   const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({ | ||||||
|     resource: 'media', |     resource: "media", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'media_name', |         field: "media_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ] | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|  |         <LanguageSelector /> | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="city_id" |           name="city_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...cityAutocompleteProps} |               {...cityAutocompleteProps} | ||||||
|               value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 cityAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) ?? null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id ?? ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.name : '' |                 return item ? item.name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите город" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.city_id} | ||||||
|  |                   helperText={(errors as any)?.city_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('full_name', { |           {...register("full_name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.full_name} |           error={!!(errors as any)?.full_name} | ||||||
|           helperText={(errors as any)?.full_name?.message} |           helperText={(errors as any)?.full_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{inputLabel: {shrink: true}}} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Полное имя *'} |           label={"Полное имя *"} | ||||||
|           name="full_name" |           name="full_name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('short_name', { |           {...register("short_name", { | ||||||
|             required: 'Это поле является обязательным', |             //required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.short_name} |           error={!!(errors as any)?.short_name} | ||||||
|           helperText={(errors as any)?.short_name?.message} |           helperText={(errors as any)?.short_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{inputLabel: {shrink: true}}} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Короткое имя *'} |           label={"Короткое имя"} | ||||||
|           name="short_name" |           name="short_name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <Box component="form" | ||||||
|           {...register('main_color', { |           sx={{ display: "flex" }} | ||||||
|             // required: 'Это поле является обязательным', |           autoComplete="off" | ||||||
|           })} |         > | ||||||
|           error={!!(errors as any)?.main_color} |           <TextField | ||||||
|           helperText={(errors as any)?.main_color?.message} |             {...register("main_color", { | ||||||
|           margin="normal" |               // required: 'Это поле является обязательным', | ||||||
|           fullWidth |             })} | ||||||
|           InputLabelProps={{shrink: true}} |             error={!!(errors as any)?.main_color} | ||||||
|           type="color" |             helperText={(errors as any)?.main_color?.message} | ||||||
|           label={'Основной цвет'} |             margin="normal" | ||||||
|           name="main_color" |             fullWidth | ||||||
|           sx={{ |             slotProps={{inputLabel: {shrink: true}}} | ||||||
|             '& input': { |             type="color" | ||||||
|               height: '50px', |             label={"Основной цвет"} | ||||||
|               paddingBlock: '14px', |             name="main_color" | ||||||
|               paddingInline: '14px', |             sx={{ | ||||||
|               cursor: 'pointer', |               "& input": { | ||||||
|             }, |                 height: "50px", | ||||||
|           }} |                 paddingBlock: "14px", | ||||||
|         /> |                 paddingInline: "14px", | ||||||
|  |                 cursor: "pointer", | ||||||
|  |               }, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("left_color", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.left_color} | ||||||
|  |             helperText={(errors as any)?.left_color?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{inputLabel: {shrink: true}}} | ||||||
|  |             type="color" | ||||||
|  |             label={"Цвет левого виджета"} | ||||||
|  |             name="left_color" | ||||||
|  |             sx={{ | ||||||
|  |               marginLeft: "16px", | ||||||
|  |               marginRight: "16px", | ||||||
|  |               "& input": { | ||||||
|  |                 height: "50px", | ||||||
|  |                 paddingBlock: "14px", | ||||||
|  |                 paddingInline: "14px", | ||||||
|  |                 cursor: "pointer", | ||||||
|  |               }, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |           <TextField | ||||||
|  |             {...register("right_color", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.right_color} | ||||||
|  |             helperText={(errors as any)?.right_color?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{inputLabel: {shrink: true}}} | ||||||
|  |             type="color" | ||||||
|  |             label={"Цвет правого виджета"} | ||||||
|  |             name="right_color" | ||||||
|  |             sx={{ | ||||||
|  |               "& input": { | ||||||
|  |                 height: "50px", | ||||||
|  |                 paddingBlock: "14px", | ||||||
|  |                 paddingInline: "14px", | ||||||
|  |                 cursor: "pointer", | ||||||
|  |               }, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         </Box> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('left_color', { |           {...register("slogan", { | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.left_color} |  | ||||||
|           helperText={(errors as any)?.left_color?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="color" |  | ||||||
|           label={'Цвет левого виджета'} |  | ||||||
|           name="left_color" |  | ||||||
|           sx={{ |  | ||||||
|             '& input': { |  | ||||||
|               height: '50px', |  | ||||||
|               paddingBlock: '14px', |  | ||||||
|               paddingInline: '14px', |  | ||||||
|               cursor: 'pointer', |  | ||||||
|             }, |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|         <TextField |  | ||||||
|           {...register('right_color', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.right_color} |  | ||||||
|           helperText={(errors as any)?.right_color?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="color" |  | ||||||
|           label={'Цвет правого виджета'} |  | ||||||
|           name="right_color" |  | ||||||
|           sx={{ |  | ||||||
|             '& input': { |  | ||||||
|               height: '50px', |  | ||||||
|               paddingBlock: '14px', |  | ||||||
|               paddingInline: '14px', |  | ||||||
|               cursor: 'pointer', |  | ||||||
|             }, |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('slogan', { |  | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.slogan} |           error={!!(errors as any)?.slogan} | ||||||
|           helperText={(errors as any)?.slogan?.message} |           helperText={(errors as any)?.slogan?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{inputLabel: {shrink: true}}} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Слоган'} |           label={"Слоган"} | ||||||
|           name="slogan" |           name="slogan" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
| @@ -174,27 +211,48 @@ export const CarrierEdit = () => { | |||||||
|           name="logo" |           name="logo" | ||||||
|           // rules={{required: 'Это поле является обязательным'}} |           // rules={{required: 'Это поле является обязательным'}} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...mediaAutocompleteProps} |               {...mediaAutocompleteProps} | ||||||
|               value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 mediaAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) ?? null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id ?? ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.media_name : '' |                 return item ? item.media_name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.media_name | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) && | ||||||
|  |                   option.media_type == 3 | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите логотип" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.logo} | ||||||
|  |                   helperText={(errors as any)?.logo?.message} | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |         <Box height={150} sx={{display: "flex", justifyContent: "start"}}> | ||||||
|  |           <MediaView media={{id: watch("logo"), media_type: 1}} /> | ||||||
|  |         </Box> | ||||||
|       </Box> |       </Box> | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,110 +1,181 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import React from 'react' |   DeleteButton, | ||||||
|  |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import React from "react"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { cityStore } from "../../store/CityStore"; | ||||||
|  | import { languageStore } from "../../store/LanguageStore"; | ||||||
|  |  | ||||||
| export const CarrierList = () => { | export const CarrierList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({}) |   const { city_id } = cityStore; | ||||||
|  |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "carrier", | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     filters: { | ||||||
|  |       permanent: [ | ||||||
|  |         { | ||||||
|  |           field: "cityID", | ||||||
|  |           operator: "eq", | ||||||
|  |           value: city_id === "0" ? null : city_id, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 50, |         minWidth: 50, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'city_id', |         field: "city_id", | ||||||
|         headerName: 'ID Города', |         headerName: "ID Города", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 100, |         minWidth: 100, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'full_name', |         field: "full_name", | ||||||
|         headerName: 'Полное имя', |         headerName: "Полное имя", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 200, |         minWidth: 200, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'short_name', |         field: "short_name", | ||||||
|         headerName: 'Короткое имя', |         headerName: "Короткое имя", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 125, |         minWidth: 125, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'city', |         field: "city", | ||||||
|         headerName: 'Город', |         headerName: "Город", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 125, |         minWidth: 125, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'main_color', |         field: "main_color", | ||||||
|         headerName: 'Основной цвет', |         headerName: "Основной цвет", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>, |         renderCell: ({ value }) => ( | ||||||
|  |           <div | ||||||
|  |             style={{ | ||||||
|  |               display: "grid", | ||||||
|  |               placeItems: "center", | ||||||
|  |               width: "100%", | ||||||
|  |               height: "100%", | ||||||
|  |               backgroundColor: `${value}10`, | ||||||
|  |               borderRadius: 10, | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             {value} | ||||||
|  |           </div> | ||||||
|  |         ), | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'left_color', |         field: "left_color", | ||||||
|         headerName: 'Цвет левого виджета', |         headerName: "Цвет левого виджета", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>, |         renderCell: ({ value }) => ( | ||||||
|  |           <div | ||||||
|  |             style={{ | ||||||
|  |               display: "grid", | ||||||
|  |               placeItems: "center", | ||||||
|  |               width: "100%", | ||||||
|  |               height: "100%", | ||||||
|  |               backgroundColor: `${value}10`, | ||||||
|  |               borderRadius: 10, | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             {value} | ||||||
|  |           </div> | ||||||
|  |         ), | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'right_color', |         field: "right_color", | ||||||
|         headerName: 'Цвет правого виджета', |         headerName: "Цвет правого виджета", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>, |         renderCell: ({ value }) => ( | ||||||
|  |           <div | ||||||
|  |             style={{ | ||||||
|  |               display: "grid", | ||||||
|  |               placeItems: "center", | ||||||
|  |               width: "100%", | ||||||
|  |               height: "100%", | ||||||
|  |               backgroundColor: `${value}10`, | ||||||
|  |               borderRadius: 10, | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             {value} | ||||||
|  |           </div> | ||||||
|  |         ), | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'logo', |         field: "logo", | ||||||
|         headerName: 'Лого', |         headerName: "Лого", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'slogan', |         field: "slogan", | ||||||
|         headerName: 'Слоган', |         headerName: "Слоган", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "actions", | ||||||
|         headerName: 'Действия', |         headerName: "Действия", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} /> |       <CustomDataGrid {...dataGridProps} languageEnabled columns={columns} /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,60 +1,111 @@ | |||||||
| import {Box, Stack, Typography} from '@mui/material' | import { Box, Stack, Typography } from "@mui/material"; | ||||||
| import {useShow} from '@refinedev/core' | import { useShow } from "@refinedev/core"; | ||||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||||
| import {TOKEN_KEY} from '../../authProvider' | import { TOKEN_KEY } from "@providers"; | ||||||
|  | import { MediaView } from "@ui"; | ||||||
|  |  | ||||||
| export type FieldType = { | export type FieldType = { | ||||||
|   label: string |   label: string; | ||||||
|   data: any |   data: any; | ||||||
|   render?: (value: any) => React.ReactNode |   render?: (value: any) => React.ReactNode; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const CarrierShow = () => { | export const CarrierShow = () => { | ||||||
|   const {query} = useShow({}) |   const { query } = useShow({}); | ||||||
|   const {data, isLoading} = query |   const { data, isLoading } = query; | ||||||
|  |  | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|  |  | ||||||
|   const fields: FieldType[] = [ |   const fields: FieldType[] = [ | ||||||
|     {label: 'Полное имя', data: 'full_name'}, |     { label: "Полное имя", data: "full_name" }, | ||||||
|     {label: 'Короткое имя', data: 'short_name'}, |     { label: "Короткое имя", data: "short_name" }, | ||||||
|     {label: 'Город', data: 'city'}, |     { label: "Город", data: "city" }, | ||||||
|     { |     { | ||||||
|       label: 'Основной цвет', |       label: "Основной цвет", | ||||||
|       data: 'main_color', |       data: "main_color", | ||||||
|       render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, |       render: (value: string) => ( | ||||||
|  |         <Box | ||||||
|  |           sx={{ | ||||||
|  |             display: "grid", | ||||||
|  |             placeItems: "center", | ||||||
|  |             width: "fit-content", | ||||||
|  |             paddingInline: "6px", | ||||||
|  |             height: "100%", | ||||||
|  |             backgroundColor: `${value}20`, | ||||||
|  |             borderRadius: 1, | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {value} | ||||||
|  |         </Box> | ||||||
|  |       ), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       label: 'Цвет левого виджета', |       label: "Цвет левого виджета", | ||||||
|       data: 'left_color', |       data: "left_color", | ||||||
|       render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, |       render: (value: string) => ( | ||||||
|  |         <Box | ||||||
|  |           sx={{ | ||||||
|  |             display: "grid", | ||||||
|  |             placeItems: "center", | ||||||
|  |             width: "fit-content", | ||||||
|  |             paddingInline: "6px", | ||||||
|  |             height: "100%", | ||||||
|  |             backgroundColor: `${value}20`, | ||||||
|  |             borderRadius: 1, | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {value} | ||||||
|  |         </Box> | ||||||
|  |       ), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       label: 'Цвет правого виджета', |       label: "Цвет правого виджета", | ||||||
|       data: 'right_color', |       data: "right_color", | ||||||
|       render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, |       render: (value: string) => ( | ||||||
|  |         <Box | ||||||
|  |           sx={{ | ||||||
|  |             display: "grid", | ||||||
|  |             placeItems: "center", | ||||||
|  |             width: "fit-content", | ||||||
|  |             paddingInline: "6px", | ||||||
|  |             height: "100%", | ||||||
|  |             backgroundColor: `${value}20`, | ||||||
|  |             borderRadius: 1, | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {value} | ||||||
|  |         </Box> | ||||||
|  |       ), | ||||||
|     }, |     }, | ||||||
|     {label: 'Слоган', data: 'slogan'}, |     { label: "Слоган", data: "slogan" }, | ||||||
|     { |     { | ||||||
|       label: 'Логотип', |       label: "Логотип", | ||||||
|       data: 'logo', |       data: "logo", | ||||||
|       render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />, |       render: (value: number) => ( | ||||||
|  |         <Box height={150} sx={{display: "flex", justifyContent: "start"}}> | ||||||
|  |           <MediaView media={{id: value, media_type: 1}} /> | ||||||
|  |         </Box> | ||||||
|  |       ), | ||||||
|     }, |     }, | ||||||
|   ] |   ]; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show isLoading={isLoading}> |     <Show isLoading={isLoading}> | ||||||
|       <Stack gap={4}> |       <Stack gap={4}> | ||||||
|         {fields.map(({label, data, render}) => ( |         {fields.map(({ label, data, render }) => ( | ||||||
|           <Stack key={data} gap={1}> |           <Stack key={data} gap={1}> | ||||||
|             <Typography variant="body1" fontWeight="bold"> |             <Typography variant="body1" fontWeight="bold"> | ||||||
|               {label} |               {label} | ||||||
|             </Typography> |             </Typography> | ||||||
|  |  | ||||||
|             {render ? render(record?.[data]) : <TextField value={record?.[data]} />} |             {render ? ( | ||||||
|  |               render(record?.[data]) | ||||||
|  |             ) : ( | ||||||
|  |               <TextField value={record?.[data]} /> | ||||||
|  |             )} | ||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,69 +1,224 @@ | |||||||
| import {Autocomplete, Box, TextField} from '@mui/material' | import { Autocomplete, Box, TextField } from "@mui/material"; | ||||||
| import {Create, useAutocomplete} from '@refinedev/mui' | import { Create, useAutocomplete } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
| import {Controller} from 'react-hook-form' | import { Controller } from "react-hook-form"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { EVERY_LANGUAGE, Languages, languageStore } from "@stores"; | ||||||
|  | import { LanguageSwitch } from "@/components/LanguageSwitch"; | ||||||
|  | import { axiosInstanceForGet } from "@/providers"; | ||||||
|  | import { useNavigate } from "react-router"; | ||||||
|  | import { useNotification } from "@refinedev/core"; | ||||||
|  |  | ||||||
| export const CityCreate = () => { | export const CityCreate = () => { | ||||||
|  |   const { language, setLanguageAction } = languageStore; | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const notification = useNotification(); | ||||||
|  |  | ||||||
|  |   // State to manage city name translations across all supported languages. | ||||||
|  |   // Initializes with empty strings for each language. | ||||||
|  |   const [allLanguageNames, setAllLanguageNames] = useState< | ||||||
|  |     Record<Languages, string> | ||||||
|  |   >(EVERY_LANGUAGE("")); | ||||||
|  |  | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {formLoading}, |     refineCore: { formLoading }, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     setValue, | ||||||
|   } = useForm({}) |     watch, | ||||||
|  |     handleSubmit, | ||||||
|  |     formState: { errors }, | ||||||
|  |   } = useForm<{ | ||||||
|  |     name: string; | ||||||
|  |     country_code: string; | ||||||
|  |     arms: string; | ||||||
|  |   }>({}); | ||||||
|  |  | ||||||
|   const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({ |   // Keeps the 'name' input field synchronized with the currently active language's translation. | ||||||
|     resource: 'country', |   // Updates whenever the active language or the `allLanguageNames` state changes. | ||||||
|   }) |   useEffect(() => { | ||||||
|  |     setValue("name", allLanguageNames[language]); | ||||||
|  |   }, [language, allLanguageNames, setValue]); | ||||||
|  |  | ||||||
|   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ |   // Captures the current value of the 'name' TextField and updates the `allLanguageNames` state. | ||||||
|     resource: 'media', |   // This is vital for preserving user input when switching languages or before form submission. | ||||||
|  |   const updateCurrentLanguageName = () => { | ||||||
|  |     const currentNameValue = watch("name"); | ||||||
|  |     setAllLanguageNames((prev) => ({ | ||||||
|  |       ...prev, | ||||||
|  |       [language]: currentNameValue || "", | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Handles language changes. It first saves the current input, then updates the active language. | ||||||
|  |   const handleLanguageChange = (lang: Languages) => { | ||||||
|  |     updateCurrentLanguageName(); | ||||||
|  |     setLanguageAction(lang); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Autocomplete hooks for selecting a country and city arms (media). | ||||||
|  |   const { autocompleteProps: countryAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "country", | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "media", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'media_name', |         field: "media_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|  |   // --- Form Submission Logic --- | ||||||
|  |  | ||||||
|  |   // Handles the form submission. It saves the current language's input, | ||||||
|  |   // validates the Russian name, and then sends requests to create/update city data | ||||||
|  |   // across different languages. | ||||||
|  |   const onFinish = async (data: { | ||||||
|  |     name: string; | ||||||
|  |     country_code: string; | ||||||
|  |     arms: string; | ||||||
|  |   }) => { | ||||||
|  |     updateCurrentLanguageName(); | ||||||
|  |  | ||||||
|  |     const finalNames = { | ||||||
|  |       ...allLanguageNames, | ||||||
|  |       [language]: data.name, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       if (!finalNames.ru) { | ||||||
|  |         console.error("Russian name is required for initial city creation."); | ||||||
|  |         if (notification && typeof notification.open === "function") { | ||||||
|  |           notification.open({ | ||||||
|  |             message: "Ошибка", | ||||||
|  |             description: "Русское название города обязательно для создания.", | ||||||
|  |             type: "error", | ||||||
|  |           }); | ||||||
|  |         } | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       console.log("Submitting with names:", finalNames); | ||||||
|  |  | ||||||
|  |       // Create the city with the Russian name first. | ||||||
|  |       const ruResponse = await axiosInstanceForGet("ru").post("/city", { | ||||||
|  |         name: finalNames.ru, | ||||||
|  |         country_code: data.country_code, | ||||||
|  |         arms: data.arms, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       const id = ruResponse.data.id; | ||||||
|  |  | ||||||
|  |       // Update the city with English and Chinese names if available. | ||||||
|  |       if (finalNames.en) { | ||||||
|  |         await axiosInstanceForGet("en").patch(`/city/${id}`, { | ||||||
|  |           name: finalNames.en, | ||||||
|  |           country_code: data.country_code, | ||||||
|  |           arms: data.arms, | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (finalNames.zh) { | ||||||
|  |         await axiosInstanceForGet("zh").patch(`/city/${id}`, { | ||||||
|  |           name: finalNames.zh, | ||||||
|  |           country_code: data.country_code, | ||||||
|  |           arms: data.arms, | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       console.log("City created/updated successfully!"); | ||||||
|  |       if (notification && typeof notification.open === "function") { | ||||||
|  |         notification.open({ | ||||||
|  |           message: "Город успешно создан", | ||||||
|  |           type: "success", | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       navigate("/city", { replace: true }); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error("Error creating/updating city:", error); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> |     <Create | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       isLoading={formLoading} | ||||||
|  |       saveButtonProps={{ | ||||||
|  |         ...saveButtonProps, | ||||||
|  |         disabled: saveButtonProps.disabled, | ||||||
|  |         onClick: handleSubmit(onFinish as any), | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|  |         <LanguageSwitch action={handleLanguageChange} /> | ||||||
|  |  | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="country_code" |           name="country_code" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...countryAutocompleteProps} |               {...countryAutocompleteProps} | ||||||
|               value={countryAutocompleteProps.options.find((option) => option.code === field.value) || null} |               value={ | ||||||
|               onChange={(_, value) => { |                 countryAutocompleteProps.options.find( | ||||||
|                 field.onChange(value?.code || '') |                   (option: { code: string; name: string; id: string }) => | ||||||
|  |                     option.code === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|  |               onChange={( | ||||||
|  |                 _, | ||||||
|  |                 value: { code: string; name: string; id: string } | null | ||||||
|  |               ) => { | ||||||
|  |                 field.onChange(value?.code || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item: { | ||||||
|                 return item ? item.name : '' |                 code: string; | ||||||
|  |                 name: string; | ||||||
|  |                 id: string; | ||||||
|  |               }) => { | ||||||
|  |                 return item ? item.name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={( | ||||||
|                 return option.id === value?.id |                 option: { code: string; name: string; id: string }, | ||||||
|  |                 value: { code: string; name: string; id: string } | ||||||
|  |               ) => { | ||||||
|  |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите страну" margin="normal" variant="outlined" error={!!errors.country_code} helperText={(errors as any)?.country_code?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите страну" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.country_code} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('name', { |           {...register("name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|  |             onBlur: updateCurrentLanguageName, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.name} |           error={!!errors.name} | ||||||
|           helperText={(errors as any)?.name?.message} |  | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Название *'} |           label={"Название *"} | ||||||
|           name="name" |           name="name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
| @@ -71,27 +226,53 @@ export const CityCreate = () => { | |||||||
|           control={control} |           control={control} | ||||||
|           name="arms" |           name="arms" | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...mediaAutocompleteProps} |               {...mediaAutocompleteProps} | ||||||
|               value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|               onChange={(_, value) => { |                 mediaAutocompleteProps.options.find( | ||||||
|                 field.onChange(value?.id || '') |                   (option: { id: string; media_name: string }) => | ||||||
|  |                     option.id === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|  |               onChange={( | ||||||
|  |                 _, | ||||||
|  |                 value: { id: string; media_name: string } | null | ||||||
|  |               ) => { | ||||||
|  |                 field.onChange(value?.id || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item: { id: string; media_name: string }) => { | ||||||
|                 return item ? item.media_name : '' |                 return item ? item.media_name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={( | ||||||
|                 return option.id === value?.id |                 option: { id: string; media_name: string }, | ||||||
|  |                 value: { id: string; media_name: string } | ||||||
|  |               ) => { | ||||||
|  |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={( | ||||||
|                 return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) |                 options: { id: string; media_name: string }[], | ||||||
|  |                 { inputValue } | ||||||
|  |               ) => { | ||||||
|  |                 return options.filter((option) => | ||||||
|  |                   option.media_name | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите герб" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите герб" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.arms} | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|     </Create> |     </Create> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,18 +1,26 @@ | |||||||
| import {Autocomplete, Box, TextField} from '@mui/material' | import {Autocomplete, Box, TextField} from '@mui/material' | ||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' | import {Edit, useAutocomplete} from '@refinedev/mui' | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import {useForm} from '@refinedev/react-hook-form' | ||||||
|  | import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from '@stores' | ||||||
|  | import { LanguageSelector } from '@ui' | ||||||
|  | import { observer } from 'mobx-react-lite' | ||||||
|  | import { useEffect, useState } from 'react' | ||||||
| import {Controller} from 'react-hook-form' | import {Controller} from 'react-hook-form' | ||||||
|  |  | ||||||
| export const CityEdit = () => { | export const CityEdit = observer(() => { | ||||||
|  | 	const { language } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     formState: {errors}, | ||||||
|   } = useForm({}) |   } = useForm({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language) | ||||||
|  |   }) | ||||||
|  |  | ||||||
|   const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({ |   const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({ | ||||||
|     resource: 'country', |     resource: 'country', | ||||||
|  |     ...META_LANGUAGE(language) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ |   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ | ||||||
| @@ -24,11 +32,14 @@ export const CityEdit = () => { | |||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |     ...META_LANGUAGE(language) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||||
|  |         <LanguageSelector/> | ||||||
|  |  | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="country_code" |           name="country_code" | ||||||
| @@ -94,4 +105,4 @@ export const CityEdit = () => { | |||||||
|       </Box> |       </Box> | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ) | ||||||
| } | }) | ||||||
| @@ -1,78 +1,100 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import React from 'react' |   DeleteButton, | ||||||
|  |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import React, { useEffect } from "react"; | ||||||
|  |  | ||||||
| export const CityList = () => { | import { observer } from "mobx-react-lite"; | ||||||
|   const {dataGridProps} = useDataGrid({}) | import { languageStore } from "../../store/LanguageStore"; | ||||||
|  |  | ||||||
|  | export const CityList = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "city", | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 50, |         minWidth: 50, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'country_code', |         field: "country_code", | ||||||
|         headerName: 'Код страны', |         headerName: "Код страны", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'country', |         field: "country", | ||||||
|         headerName: 'Cтрана', |         headerName: "Cтрана", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         headerName: 'Название', |         headerName: "Название", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'arms', |         field: "arms", | ||||||
|         headerName: 'Герб', |         headerName: "Герб", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "actions", | ||||||
|         headerName: 'Действия', |         headerName: "Действия", | ||||||
|         cellClassName: 'city-actions', |         cellClassName: "city-actions", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} /> |       <CustomDataGrid {...dataGridProps} columns={columns} languageEnabled /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,35 +1,51 @@ | |||||||
| import {Stack, Typography} from '@mui/material' | import { Stack, Typography } from "@mui/material"; | ||||||
| import {useShow} from '@refinedev/core' | import { useShow } from "@refinedev/core"; | ||||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||||
| import {TOKEN_KEY} from '../../authProvider' | import { TOKEN_KEY } from "@providers"; | ||||||
|  |  | ||||||
| export const CityShow = () => { | export const CityShow = () => { | ||||||
|   const {query} = useShow({}) |   const { query } = useShow({}); | ||||||
|   const {data, isLoading} = query |   const { data, isLoading } = query; | ||||||
|  |  | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|  |  | ||||||
|   const fields = [ |   const fields = [ | ||||||
|     // {label: 'ID', data: 'id'}, |     // {label: 'ID', data: 'id'}, | ||||||
|     {label: 'Название', data: 'name'}, |     { label: "Название", data: "name" }, | ||||||
|     // {label: 'Код страны', data: 'country_code'}, |     // {label: 'Код страны', data: 'country_code'}, | ||||||
|     {label: 'Страна', data: 'country'}, |     { label: "Страна", data: "country" }, | ||||||
|     {label: 'Герб', data: 'arms', render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />}, |     { | ||||||
|   ] |       label: "Герб", | ||||||
|  |       data: "arms", | ||||||
|  |       render: (value: number) => ( | ||||||
|  |         <img | ||||||
|  |           src={`${ | ||||||
|  |             import.meta.env.VITE_KRBL_MEDIA | ||||||
|  |           }${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||||
|  |           alt={String(value)} | ||||||
|  |           style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }} | ||||||
|  |         /> | ||||||
|  |       ), | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show isLoading={isLoading}> |     <Show isLoading={isLoading}> | ||||||
|       <Stack gap={4}> |       <Stack gap={4}> | ||||||
|         {fields.map(({label, data, render}) => ( |         {fields.map(({ label, data, render }) => ( | ||||||
|           <Stack key={data} gap={1}> |           <Stack key={data} gap={1}> | ||||||
|             <Typography variant="body1" fontWeight="bold"> |             <Typography variant="body1" fontWeight="bold"> | ||||||
|               {label} |               {label} | ||||||
|             </Typography> |             </Typography> | ||||||
|  |  | ||||||
|             {render ? render(record?.[data]) : <TextField value={record?.[data]} />} |             {render ? ( | ||||||
|  |               render(record?.[data]) | ||||||
|  |             ) : ( | ||||||
|  |               <TextField value={record?.[data]} /> | ||||||
|  |             )} | ||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,44 +1,56 @@ | |||||||
| import {Box, TextField} from '@mui/material' | import { Box, TextField } from "@mui/material"; | ||||||
| import {Edit} from '@refinedev/mui' | import { Edit } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  | import { LanguageSelector } from "@ui"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
| export const CountryEdit = () => { | export const CountryEdit = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|   } = useForm({}) |   } = useForm({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|  |         <LanguageSelector /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('code', { |           {...register("code", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.code} |           error={!!(errors as any)?.code} | ||||||
|           helperText={(errors as any)?.code?.message} |           helperText={(errors as any)?.code?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Код *'} |           label={"Код *"} | ||||||
|  |           disabled | ||||||
|           name="code" |           name="code" | ||||||
|         /> |         /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('name', { |           {...register("name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.name} |           error={!!(errors as any)?.name} | ||||||
|           helperText={(errors as any)?.name?.message} |           helperText={(errors as any)?.name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Название *'} |           label={"Название *"} | ||||||
|           name="name" |           name="name" | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,56 +1,82 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import React from 'react' |   DeleteButton, | ||||||
|  |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import React from "react"; | ||||||
|  | import { languageStore } from "../../store/LanguageStore"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
| export const CountryList = () => { | export const CountryList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({}) |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "country", | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'code', |         field: "code", | ||||||
|         headerName: 'Код', |         headerName: "Код", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         headerName: 'Название', |         headerName: "Название", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "actions", | ||||||
|         headerName: 'Действия', |         headerName: "Действия", | ||||||
|         cellClassName: 'country-actions', |         cellClassName: "country-actions", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.code} /> |               <EditButton hideText recordItemId={row.code} /> | ||||||
|               <ShowButton hideText recordItemId={row.code} /> |               <ShowButton hideText recordItemId={row.code} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.code} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.code} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} getRowId={(row: any) => row.code} /> |       <CustomDataGrid | ||||||
|  |         {...dataGridProps} | ||||||
|  |         languageEnabled | ||||||
|  |         columns={columns} | ||||||
|  |         getRowId={(row: any) => row.code} | ||||||
|  |       /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,13 +1,20 @@ | |||||||
| import {AuthPage, ThemedTitleV2} from '@refinedev/mui' | import { AuthPage, ThemedTitleV2 } from "@refinedev/mui"; | ||||||
| import {ProjectIcon} from '../../components/ui/Icons' |  | ||||||
|  | import { Logo } from "@/icons/Logo"; | ||||||
|  |  | ||||||
| export const Login = () => { | export const Login = () => { | ||||||
|   return ( |   return ( | ||||||
|     <AuthPage |     <AuthPage | ||||||
|       type="login" |       type="login" | ||||||
|       title={<ThemedTitleV2 collapsed={false} text="Белые Ночи" icon={<ProjectIcon style={{color: '#7f6b58'}} />} />} |       title={ | ||||||
|  |         <ThemedTitleV2 | ||||||
|  |           collapsed={false} | ||||||
|  |           text="Белые Ночи" | ||||||
|  |           icon={<Logo width={24} height={24} />} | ||||||
|  |         /> | ||||||
|  |       } | ||||||
|       forgotPasswordLink={false} |       forgotPasswordLink={false} | ||||||
|       registerLink={false} // only admin can add users |       registerLink={false} // only admin can add users | ||||||
|     /> |     /> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,39 +1,58 @@ | |||||||
| import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material' | import { | ||||||
| import {Create} from '@refinedev/mui' |   Box, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   TextField, | ||||||
| import {Controller} from 'react-hook-form' |   Button, | ||||||
|  |   Typography, | ||||||
|  |   Autocomplete, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Create } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| import {MEDIA_TYPES} from '../../lib/constants' | import { MEDIA_TYPES } from "../../lib/constants"; | ||||||
| import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils' | import { | ||||||
|  |   ALLOWED_IMAGE_TYPES, | ||||||
|  |   ALLOWED_ICON_TYPES, | ||||||
|  |   ALLOWED_PANORAMA_TYPES, | ||||||
|  |   ALLOWED_VIDEO_TYPES, | ||||||
|  |   ALLOWED_WATERMARK_TYPES, | ||||||
|  |   ALLOWED_3D_MODEL_TYPES, | ||||||
|  |   useMediaFileUpload, | ||||||
|  | } from "../../components/media/MediaFormUtils"; | ||||||
|  | import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; | ||||||
|  | import { ModelViewer } from "@ui"; | ||||||
|  |  | ||||||
| type MediaFormValues = { | type MediaFormValues = { | ||||||
|   media_name: string |   media_name: string; | ||||||
|   media_type: number |   media_type: number; | ||||||
|   file?: File |   file?: File; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const MediaCreate = () => { | export const MediaCreate = () => { | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {formLoading, onFinish}, |     refineCore: { formLoading, onFinish }, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|     setValue, |     setValue, | ||||||
|     handleSubmit, |     handleSubmit, | ||||||
|     watch, |     watch, | ||||||
|     setError, |     setError, | ||||||
|     clearErrors, |     clearErrors, | ||||||
|   } = useForm<MediaFormValues>({}) |     getValues, | ||||||
|  |   } = useForm<MediaFormValues>({}); | ||||||
|  |  | ||||||
|   const selectedMediaType = watch('media_type') |   const selectedMediaType = watch("media_type"); | ||||||
|  |   const file = getValues("file"); | ||||||
|  |  | ||||||
|   const {selectedFile, previewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({ |   const { selectedFile, previewUrl, handleFileChange, handleMediaTypeChange } = | ||||||
|     selectedMediaType, |     useMediaFileUpload({ | ||||||
|     setError, |       selectedMediaType, | ||||||
|     clearErrors, |       setError, | ||||||
|     setValue, |       clearErrors, | ||||||
|   }) |       setValue, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Create |     <Create | ||||||
| @@ -42,19 +61,20 @@ export const MediaCreate = () => { | |||||||
|         ...saveButtonProps, |         ...saveButtonProps, | ||||||
|         disabled: !!errors.file || !selectedFile, |         disabled: !!errors.file || !selectedFile, | ||||||
|         onClick: handleSubmit((data) => { |         onClick: handleSubmit((data) => { | ||||||
|  |           console.log(data); | ||||||
|           if (data.file) { |           if (data.file) { | ||||||
|             const formData = new FormData() |             const formData = new FormData(); | ||||||
|             formData.append('media_name', data.media_name) |             formData.append("media_name", data.media_name); | ||||||
|             formData.append('filename', data.file.name) |             formData.append("filename", data.file.name); | ||||||
|             formData.append('type', String(data.media_type)) |             formData.append("type", String(data.media_type)); | ||||||
|             formData.append('file', data.file) |             formData.append("file", data.file); | ||||||
|  |  | ||||||
|             console.log('Отправляемые данные:') |             console.log("Отправляемые данные:"); | ||||||
|             for (const pair of formData.entries()) { |             for (const pair of formData.entries()) { | ||||||
|               console.log(pair[0] + ': ' + pair[1]) |               console.log(pair[0] + ": " + pair[1]); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             onFinish(formData) |             onFinish(formData); | ||||||
|           } |           } | ||||||
|         }), |         }), | ||||||
|       }} |       }} | ||||||
| @@ -63,47 +83,97 @@ export const MediaCreate = () => { | |||||||
|         control={control} |         control={control} | ||||||
|         name="media_type" |         name="media_type" | ||||||
|         rules={{ |         rules={{ | ||||||
|           required: 'Это поле является обязательным', |           required: "Это поле является обязательным", | ||||||
|         }} |         }} | ||||||
|         render={({field}) => ( |         render={({ field }) => ( | ||||||
|           <Autocomplete |           <Autocomplete | ||||||
|             options={MEDIA_TYPES} |             options={MEDIA_TYPES} | ||||||
|             value={MEDIA_TYPES.find((option) => option.value === field.value) || null} |             value={ | ||||||
|  |               MEDIA_TYPES.find((option) => option.value === field.value) || null | ||||||
|  |             } | ||||||
|             onChange={(_, value) => { |             onChange={(_, value) => { | ||||||
|               field.onChange(value?.value || null) |               field.onChange(value?.value || null); | ||||||
|               handleMediaTypeChange(value?.value || null) |               handleMediaTypeChange(value?.value || null); | ||||||
|             }} |             }} | ||||||
|             getOptionLabel={(item) => { |             getOptionLabel={(item) => { | ||||||
|               return item ? item.label : '' |               return item ? item.label : ""; | ||||||
|             }} |             }} | ||||||
|             isOptionEqualToValue={(option, value) => { |             isOptionEqualToValue={(option, value) => { | ||||||
|               return option.value === value?.value |               return option.value === value?.value; | ||||||
|             }} |             }} | ||||||
|             renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />} |             renderInput={(params) => ( | ||||||
|  |               <TextField | ||||||
|  |                 {...params} | ||||||
|  |                 label="Тип" | ||||||
|  |                 margin="normal" | ||||||
|  |                 variant="outlined" | ||||||
|  |                 error={!!errors.media_type} | ||||||
|  |                 helperText={(errors as any)?.media_type?.message} | ||||||
|  |                 required | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|           /> |           /> | ||||||
|         )} |         )} | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|       <TextField |       <TextField | ||||||
|         {...register('media_name', { |         {...register("media_name", { | ||||||
|           required: 'Это поле является обязательным', |           required: "Это поле является обязательным", | ||||||
|         })} |         })} | ||||||
|         error={!!(errors as any)?.media_name} |         error={!!(errors as any)?.media_name} | ||||||
|         helperText={(errors as any)?.media_name?.message} |         helperText={(errors as any)?.media_name?.message} | ||||||
|         margin="normal" |         margin="normal" | ||||||
|         fullWidth |         fullWidth | ||||||
|         InputLabelProps={{shrink: true}} |         InputLabelProps={{ shrink: true }} | ||||||
|         type="text" |         type="text" | ||||||
|         label="Название *" |         label="Название *" | ||||||
|         name="media_name" |         name="media_name" | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off" style={{marginTop: 10}}> |       <Box | ||||||
|         <Box display="flex" flexDirection="column-reverse" alignItems="center" gap={6}> |         component="form" | ||||||
|           <Box display="flex" flexDirection="column" alignItems="center" gap={2}> |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|             <Button variant="contained" component="label" disabled={!selectedMediaType}> |         autoComplete="off" | ||||||
|               {selectedFile ? 'Изменить файл' : 'Загрузить файл'} |         style={{ marginTop: 10 }} | ||||||
|               <input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} /> |       > | ||||||
|  |         <Box | ||||||
|  |           display="flex" | ||||||
|  |           flexDirection="column-reverse" | ||||||
|  |           alignItems="center" | ||||||
|  |           gap={6} | ||||||
|  |         > | ||||||
|  |           <Box | ||||||
|  |             display="flex" | ||||||
|  |             flexDirection="column" | ||||||
|  |             alignItems="center" | ||||||
|  |             gap={2} | ||||||
|  |           > | ||||||
|  |             <Button | ||||||
|  |               variant="contained" | ||||||
|  |               component="label" | ||||||
|  |               disabled={!selectedMediaType} | ||||||
|  |             > | ||||||
|  |               {selectedFile ? "Изменить файл" : "Загрузить файл"} | ||||||
|  |               <input | ||||||
|  |                 type="file" | ||||||
|  |                 hidden | ||||||
|  |                 onChange={handleFileChange} | ||||||
|  |                 accept={ | ||||||
|  |                   selectedMediaType === 6 | ||||||
|  |                     ? ALLOWED_3D_MODEL_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 1 | ||||||
|  |                     ? ALLOWED_IMAGE_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 2 | ||||||
|  |                     ? ALLOWED_VIDEO_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 3 | ||||||
|  |                     ? ALLOWED_ICON_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 4 | ||||||
|  |                     ? ALLOWED_WATERMARK_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 5 | ||||||
|  |                     ? ALLOWED_PANORAMA_TYPES.join(",") | ||||||
|  |                     : "" | ||||||
|  |                 } | ||||||
|  |               /> | ||||||
|             </Button> |             </Button> | ||||||
|  |  | ||||||
|             {selectedFile && ( |             {selectedFile && ( | ||||||
| @@ -121,11 +191,53 @@ export const MediaCreate = () => { | |||||||
|  |  | ||||||
|           {previewUrl && selectedMediaType === 1 && ( |           {previewUrl && selectedMediaType === 1 && ( | ||||||
|             <Box mt={2} display="flex" justifyContent="center"> |             <Box mt={2} display="flex" justifyContent="center"> | ||||||
|               <img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} /> |               <img | ||||||
|  |                 src={previewUrl} | ||||||
|  |                 alt="Preview" | ||||||
|  |                 style={{ maxWidth: "200px", borderRadius: 8 }} | ||||||
|  |               /> | ||||||
|             </Box> |             </Box> | ||||||
|           )} |           )} | ||||||
|  |  | ||||||
|  |           {file && selectedMediaType === 2 && ( | ||||||
|  |             <Box mt={2} display="flex" justifyContent="center"> | ||||||
|  |               <video src={URL.createObjectURL(file)} autoPlay controls /> | ||||||
|  |             </Box> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {previewUrl && selectedMediaType === 3 && ( | ||||||
|  |             <Box mt={2} display="flex" justifyContent="center"> | ||||||
|  |               <img | ||||||
|  |                 src={previewUrl} | ||||||
|  |                 alt="Preview" | ||||||
|  |                 style={{ maxWidth: "200px", borderRadius: 8 }} | ||||||
|  |               /> | ||||||
|  |             </Box> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {previewUrl && selectedMediaType === 4 && ( | ||||||
|  |             <Box mt={2} display="flex" justifyContent="center"> | ||||||
|  |               <img | ||||||
|  |                 src={previewUrl} | ||||||
|  |                 alt="Preview" | ||||||
|  |                 style={{ maxWidth: "200px", borderRadius: 8 }} | ||||||
|  |               /> | ||||||
|  |             </Box> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {file && selectedMediaType === 5 && ( | ||||||
|  |             <ReactPhotoSphereViewer | ||||||
|  |               src={URL.createObjectURL(file)} | ||||||
|  |               width={"100%"} | ||||||
|  |               height={"80vh"} | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {file && previewUrl && selectedMediaType === 6 && ( | ||||||
|  |             <ModelViewer fileUrl={URL.createObjectURL(file)} /> | ||||||
|  |           )} | ||||||
|         </Box> |         </Box> | ||||||
|       </Box> |       </Box> | ||||||
|     </Create> |     </Create> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,26 +1,44 @@ | |||||||
| import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material' | import { | ||||||
| import {Edit} from '@refinedev/mui' |   Box, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   TextField, | ||||||
| import {useEffect} from 'react' |   Button, | ||||||
| import {useShow} from '@refinedev/core' |   Typography, | ||||||
| import {Controller} from 'react-hook-form' |   Autocomplete, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Edit } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { useEffect } from "react"; | ||||||
|  | import { useShow } from "@refinedev/core"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| import {MEDIA_TYPES} from '../../lib/constants' | import { TOKEN_KEY } from "@providers"; | ||||||
| import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils' | import { MEDIA_TYPES } from "@lib"; | ||||||
| import {TOKEN_KEY} from '../../authProvider' | import { | ||||||
|  |   ALLOWED_IMAGE_TYPES, | ||||||
|  |   ALLOWED_VIDEO_TYPES, | ||||||
|  |   ALLOWED_ICON_TYPES, | ||||||
|  |   ALLOWED_WATERMARK_TYPES, | ||||||
|  |   ALLOWED_PANORAMA_TYPES, | ||||||
|  |   ALLOWED_3D_MODEL_TYPES, | ||||||
|  |   useMediaFileUpload, | ||||||
|  | } from "../../components/media/MediaFormUtils"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { LanguageSelector, MediaData, MediaView } from "@ui"; | ||||||
|  |  | ||||||
| type MediaFormValues = { | type MediaFormValues = { | ||||||
|   media_name: string |   media_name: string; | ||||||
|   media_type: number |   media_type: number; | ||||||
|   file?: File |   file?: File; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const MediaEdit = () => { | export const MediaEdit = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {onFinish}, |     refineCore: { onFinish }, | ||||||
|     register, |     register, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|     setValue, |     setValue, | ||||||
|     handleSubmit, |     handleSubmit, | ||||||
|     watch, |     watch, | ||||||
| @@ -29,32 +47,43 @@ export const MediaEdit = () => { | |||||||
|     control, |     control, | ||||||
|   } = useForm<MediaFormValues>({ |   } = useForm<MediaFormValues>({ | ||||||
|     defaultValues: { |     defaultValues: { | ||||||
|       media_name: '', |       media_name: "", | ||||||
|       media_type: '', |       media_type: "", | ||||||
|       file: undefined, |       file: undefined, | ||||||
|     }, |     }, | ||||||
|   }) |     refineCoreProps: META_LANGUAGE(language) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {query} = useShow() |   const { query } = useShow(); | ||||||
|   const {data} = query |   const { data } = query; | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|  |  | ||||||
|   const selectedMediaType = watch('media_type') |   const selectedMediaType = watch("media_type"); | ||||||
|  |  | ||||||
|   const {selectedFile, previewUrl, setPreviewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({ |   const { | ||||||
|  |     selectedFile, | ||||||
|  |     previewUrl, | ||||||
|  |     setPreviewUrl, | ||||||
|  |     handleFileChange, | ||||||
|  |     handleMediaTypeChange, | ||||||
|  |   } = useMediaFileUpload({ | ||||||
|     selectedMediaType, |     selectedMediaType, | ||||||
|     setError, |     setError, | ||||||
|     clearErrors, |     clearErrors, | ||||||
|     setValue, |     setValue, | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (record?.id) { |     if (record?.id) { | ||||||
|       setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`) |       setPreviewUrl( | ||||||
|       setValue('media_name', record?.media_name || '') |         `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|       setValue('media_type', record?.media_type) |           record.id | ||||||
|  |         }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||||
|  |       ); | ||||||
|  |       setValue("media_name", record?.media_name || ""); | ||||||
|  |       setValue("media_type", record?.media_type); | ||||||
|     } |     } | ||||||
|   }, [record, setValue, setPreviewUrl]) |   }, [record, setValue, setPreviewUrl]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit |     <Edit | ||||||
| @@ -66,57 +95,109 @@ export const MediaEdit = () => { | |||||||
|             media_name: data.media_name, |             media_name: data.media_name, | ||||||
|             filename: selectedFile?.name || record?.filename, |             filename: selectedFile?.name || record?.filename, | ||||||
|             type: Number(data.media_type), |             type: Number(data.media_type), | ||||||
|           } |           }; | ||||||
|           onFinish(formData) |           onFinish(formData); | ||||||
|         }), |         }), | ||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|  |         <LanguageSelector /> | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="media_type" |           name="media_type" | ||||||
|           rules={{ |           rules={{ | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           }} |           }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               options={MEDIA_TYPES} |               options={MEDIA_TYPES} | ||||||
|               value={MEDIA_TYPES.find((option) => option.value === field.value) || null} |               value={ | ||||||
|  |                 MEDIA_TYPES.find((option) => option.value === field.value) || | ||||||
|  |                 null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.value || null) |                 field.onChange(value?.value || null); | ||||||
|                 handleMediaTypeChange(value?.value || null) |                 handleMediaTypeChange(value?.value || null); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.label : '' |                 return item ? item.label : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.value === value?.value |                 return option.value === value?.value; | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Тип" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.media_type} | ||||||
|  |                   helperText={(errors as any)?.media_type?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('media_name', { |           {...register("media_name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.media_name} |           error={!!(errors as any)?.media_name} | ||||||
|           helperText={(errors as any)?.media_name?.message} |           helperText={(errors as any)?.media_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{inputLabel: {shrink: true}}} | ||||||
|           type="text" |           type="text" | ||||||
|           label="Название *" |           label="Название *" | ||||||
|           name="media_name" |           name="media_name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <Box display="flex" flexDirection="column-reverse" alignItems="center" gap={4} style={{marginTop: 10}}> |         <Box | ||||||
|           <Box display="flex" flexDirection="column" alignItems="center" gap={2}> |           display="flex" | ||||||
|             <Button variant="contained" component="label" disabled={!selectedMediaType}> |           flexDirection="column-reverse" | ||||||
|               {selectedFile ? 'Изменить файл' : 'Загрузить файл'} |           alignItems="center" | ||||||
|               <input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} /> |           gap={4} | ||||||
|  |           style={{ marginTop: 10 }} | ||||||
|  |         > | ||||||
|  |           <Box | ||||||
|  |             display="flex" | ||||||
|  |             flexDirection="column" | ||||||
|  |             alignItems="center" | ||||||
|  |             gap={2} | ||||||
|  |           > | ||||||
|  |             <Button | ||||||
|  |               variant="contained" | ||||||
|  |               component="label" | ||||||
|  |               disabled={!selectedMediaType} | ||||||
|  |             > | ||||||
|  |               {selectedFile ? "Изменить файл" : "Загрузить файл"} | ||||||
|  |               <input | ||||||
|  |                 type="file" | ||||||
|  |                 hidden | ||||||
|  |                 onChange={handleFileChange} | ||||||
|  |                 accept={ | ||||||
|  |                       selectedMediaType === 1 | ||||||
|  |                     ? ALLOWED_IMAGE_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 2 | ||||||
|  |                     ? ALLOWED_VIDEO_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 3 | ||||||
|  |                     ? ALLOWED_ICON_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 4 | ||||||
|  |                     ? ALLOWED_WATERMARK_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 5 | ||||||
|  |                     ? ALLOWED_PANORAMA_TYPES.join(",") | ||||||
|  |                     : selectedMediaType === 6 | ||||||
|  |                     ? ALLOWED_3D_MODEL_TYPES.join(",") | ||||||
|  |                     : "" | ||||||
|  |                 } | ||||||
|  |               /> | ||||||
|             </Button> |             </Button> | ||||||
|  |  | ||||||
|             {selectedFile && ( |             {selectedFile && ( | ||||||
| @@ -132,13 +213,19 @@ export const MediaEdit = () => { | |||||||
|             )} |             )} | ||||||
|           </Box> |           </Box> | ||||||
|  |  | ||||||
|           {previewUrl && selectedMediaType === 1 && ( |  | ||||||
|  |           <MediaView media={record as MediaData} /> | ||||||
|  |           {/* {previewUrl && selectedMediaType === 1 && ( | ||||||
|             <Box mt={2} display="flex" justifyContent="center"> |             <Box mt={2} display="flex" justifyContent="center"> | ||||||
|               <img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} /> |               <img | ||||||
|  |                 src={previewUrl} | ||||||
|  |                 alt="Preview" | ||||||
|  |                 style={{ maxWidth: "200px", borderRadius: 8 }} | ||||||
|  |               /> | ||||||
|             </Box> |             </Box> | ||||||
|           )} |           )} */} | ||||||
|         </Box> |         </Box> | ||||||
|       </Box> |       </Box> | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,83 +1,106 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import React from 'react' |   DeleteButton, | ||||||
| import {MEDIA_TYPES} from '../../lib/constants' |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import React from "react"; | ||||||
|  | import { MEDIA_TYPES } from "../../lib/constants"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
| import {localeText} from '../../locales/ru/localeText' | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  |  | ||||||
| export const MediaList = () => { | export const MediaList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({}) |   const { language } = languageStore; | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     ...META_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 350, |         minWidth: 350, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'filename', |         field: "filename", | ||||||
|         headerName: 'Название файла', |         headerName: "Название файла", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 250, |         minWidth: 250, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'media_name', |         field: "media_name", | ||||||
|         headerName: 'Название', |         headerName: "Название", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         flex: 1, | ||||||
|         align: 'left', |         display: "flex", | ||||||
|         headerAlign: 'left', |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'media_type', |         field: "media_type", | ||||||
|         headerName: 'Тип', |         headerName: "Тип", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|         flex: 1, |         flex: 1, | ||||||
|         renderCell: (params) => { |         renderCell: (params) => { | ||||||
|           const value = params.row.media_type |           const value = params.row.media_type; | ||||||
|           return MEDIA_TYPES.find((type) => type.value === value)?.label || value |           return ( | ||||||
|  |             MEDIA_TYPES.find((type) => type.value === value)?.label || value | ||||||
|  |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "actions", | ||||||
|         headerName: 'Действия', |         headerName: "Действия", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} /> |       <CustomDataGrid | ||||||
|  |         {...dataGridProps} | ||||||
|  |         columns={columns} | ||||||
|  |         localeText={localeText} | ||||||
|  |         getRowId={(row: any) => row.id} | ||||||
|  |       /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,78 +1,73 @@ | |||||||
| import {Stack, Typography, Box, Button} from '@mui/material' | import { Stack, Typography, Box, Button } from "@mui/material"; | ||||||
| import {useShow} from '@refinedev/core' | import { useShow } from "@refinedev/core"; | ||||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||||
|  | import { MEDIA_TYPES } from "@lib"; | ||||||
| import {MEDIA_TYPES} from '../../lib/constants' | import { TOKEN_KEY } from "@providers"; | ||||||
| import {TOKEN_KEY} from '../../authProvider' | import { MediaData, MediaView } from "@ui"; | ||||||
|  |  | ||||||
| export const MediaShow = () => { | export const MediaShow = () => { | ||||||
|   const {query} = useShow({}) |   const { query } = useShow({}); | ||||||
|   const {data, isLoading} = query |   const { data, isLoading } = query; | ||||||
|  |  | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|   const token = localStorage.getItem(TOKEN_KEY) |   const token = localStorage.getItem(TOKEN_KEY); | ||||||
|  |  | ||||||
|   const fields = [ |   const fields = [ | ||||||
|     // {label: 'Название файла', data: 'filename'}, |     // {label: 'Название файла', data: 'filename'}, | ||||||
|     {label: 'Название', data: 'media_name'}, |     { label: "Название", data: "media_name" }, | ||||||
|     { |     { | ||||||
|       label: 'Тип', |       label: "Тип", | ||||||
|       data: 'media_type', |       data: "media_type", | ||||||
|       render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value, |       render: (value: number) => | ||||||
|  |         MEDIA_TYPES.find((type) => type.value === value)?.label || value, | ||||||
|     }, |     }, | ||||||
|     // {label: 'ID', data: 'id'}, |     // {label: 'ID', data: 'id'}, | ||||||
|   ] |   ]; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show isLoading={isLoading}> |     <Show isLoading={isLoading}> | ||||||
|       <Stack gap={4}> |       <Stack gap={4}> | ||||||
|         {record && record.media_type === 1 && ( |         <MediaView media={record as MediaData} /> | ||||||
|           <img |         {fields.map(({ label, data, render }) => ( | ||||||
|             src={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} |  | ||||||
|             alt={record?.filename} |  | ||||||
|             style={{ |  | ||||||
|               maxWidth: '100%', |  | ||||||
|               height: '40vh', |  | ||||||
|               objectFit: 'contain', |  | ||||||
|               borderRadius: 8, |  | ||||||
|             }} |  | ||||||
|           /> |  | ||||||
|         )} |  | ||||||
|  |  | ||||||
|         {record && record.media_type === 2 && ( |  | ||||||
|           <Box |  | ||||||
|             sx={{ |  | ||||||
|               p: 2, |  | ||||||
|               border: '1px solid text.pimary', |  | ||||||
|               borderRadius: 2, |  | ||||||
|               bgcolor: 'primary.light', |  | ||||||
|               width: 'fit-content', |  | ||||||
|             }} |  | ||||||
|           > |  | ||||||
|             <Typography |  | ||||||
|               variant="body1" |  | ||||||
|               gutterBottom |  | ||||||
|               sx={{ |  | ||||||
|                 color: '#FFFFFF', |  | ||||||
|               }} |  | ||||||
|             > |  | ||||||
|               Видео доступно для скачивания по ссылке: |  | ||||||
|             </Typography> |  | ||||||
|             <Button variant="contained" href={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} target="_blank" sx={{mt: 1, width: '100%'}}> |  | ||||||
|               Скачать видео |  | ||||||
|             </Button> |  | ||||||
|           </Box> |  | ||||||
|         )} |  | ||||||
|  |  | ||||||
|         {fields.map(({label, data, render}) => ( |  | ||||||
|           <Stack key={data} gap={1}> |           <Stack key={data} gap={1}> | ||||||
|             <Typography variant="body1" fontWeight="bold"> |             <Typography variant="body1" fontWeight="bold"> | ||||||
|               {label} |               {label} | ||||||
|             </Typography> |             </Typography> | ||||||
|             <TextField value={render ? render(record?.[data]) : record?.[data]} /> |             <TextField | ||||||
|  |               value={render ? render(record?.[data]) : record?.[data]} | ||||||
|  |             /> | ||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|  |         <Box | ||||||
|  |           sx={{ | ||||||
|  |             p: 2, | ||||||
|  |             border: "1px solid text.pimary", | ||||||
|  |             borderRadius: 2, | ||||||
|  |             bgcolor: "primary.light", | ||||||
|  |             width: "fit-content", | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <Typography | ||||||
|  |             variant="body1" | ||||||
|  |             gutterBottom | ||||||
|  |             sx={{ | ||||||
|  |               color: "#FFFFFF", | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             Доступно для скачивания по ссылке: | ||||||
|  |           </Typography> | ||||||
|  |           <Button | ||||||
|  |             variant="contained" | ||||||
|  |             href={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||||
|  |               record?.id | ||||||
|  |             }/download?token=${token}`} | ||||||
|  |             target="_blank" | ||||||
|  |             sx={{ mt: 1, width: "100%" }} | ||||||
|  |           > | ||||||
|  |             Скачать медиа | ||||||
|  |           </Button> | ||||||
|  |         </Box> | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
							
								
								
									
										9
									
								
								src/pages/route-preview/Constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/pages/route-preview/Constants.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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 SCALE_FACTOR = 50; | ||||||
|  |  | ||||||
|  | export const BACKGROUND_COLOR = 0x111111; | ||||||
|  | export const PATH_COLOR = 0xff4d4d; | ||||||
							
								
								
									
										230
									
								
								src/pages/route-preview/InfiniteCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								src/pages/route-preview/InfiniteCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,230 @@ | |||||||
|  | import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js"; | ||||||
|  | import { Component, ReactNode, useEffect, useState, useRef } from "react"; | ||||||
|  | import { useTransform } from "./TransformContext"; | ||||||
|  | import { useMapData } from "./MapDataContext"; | ||||||
|  | import { SCALE_FACTOR } from "./Constants"; | ||||||
|  | import { useApplication } from "@pixi/react"; | ||||||
|  |  | ||||||
|  | class ErrorBoundary extends Component< | ||||||
|  |   { children: ReactNode }, | ||||||
|  |   { hasError: boolean } | ||||||
|  | > { | ||||||
|  |   state = { hasError: false }; | ||||||
|  |  | ||||||
|  |   static getDerivedStateFromError() { | ||||||
|  |     return { hasError: true }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   componentDidCatch(error: Error, info: React.ErrorInfo) { | ||||||
|  |     console.error("Error caught:", error, info); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   render() { | ||||||
|  |     return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function InfiniteCanvas({ | ||||||
|  |   children, | ||||||
|  | }: Readonly<{ children?: ReactNode }>) { | ||||||
|  |   const { | ||||||
|  |     position, | ||||||
|  |     setPosition, | ||||||
|  |     scale, | ||||||
|  |     setScale, | ||||||
|  |     rotation, | ||||||
|  |     setRotation, | ||||||
|  |     setScreenCenter, | ||||||
|  |     screenCenter, | ||||||
|  |   } = useTransform(); | ||||||
|  |   const { routeData, originalRouteData } = useMapData(); | ||||||
|  |  | ||||||
|  |   const applicationRef = useApplication(); | ||||||
|  |  | ||||||
|  |   const [isDragging, setIsDragging] = useState(false); | ||||||
|  |   const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||||
|  |   const [startRotation, setStartRotation] = useState(0); | ||||||
|  |   const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||||
|  |  | ||||||
|  |   // Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута | ||||||
|  |   const [isUserInteracting, setIsUserInteracting] = useState(false); | ||||||
|  |  | ||||||
|  |   // Реф для отслеживания последнего значения originalRouteData?.rotate | ||||||
|  |   const lastOriginalRotation = useRef<number | undefined>(undefined); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const canvas = applicationRef?.app.canvas; | ||||||
|  |     if (!canvas) return; | ||||||
|  |  | ||||||
|  |     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]); | ||||||
|  |  | ||||||
|  |   const handlePointerDown = (e: FederatedMouseEvent) => { | ||||||
|  |     setIsDragging(true); | ||||||
|  |     setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя | ||||||
|  |     setStartPosition({ | ||||||
|  |       x: position.x, | ||||||
|  |       y: position.y, | ||||||
|  |     }); | ||||||
|  |     setStartMousePosition({ | ||||||
|  |       x: e.globalX, | ||||||
|  |       y: e.globalY, | ||||||
|  |     }); | ||||||
|  |     setStartRotation(rotation); | ||||||
|  |     e.stopPropagation(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя | ||||||
|  |   useEffect(() => { | ||||||
|  |     const newRotation = originalRouteData?.rotate ?? 0; | ||||||
|  |  | ||||||
|  |     // Обновляем rotation только если: | ||||||
|  |     // 1. Пользователь не взаимодействует с канвасом | ||||||
|  |     // 2. Значение действительно изменилось | ||||||
|  |     if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { | ||||||
|  |       setRotation((newRotation * Math.PI) / 180); | ||||||
|  |       lastOriginalRotation.current = newRotation; | ||||||
|  |     } | ||||||
|  |   }, [originalRouteData?.rotate, isUserInteracting, setRotation]); | ||||||
|  |  | ||||||
|  |   const handlePointerMove = (e: FederatedMouseEvent) => { | ||||||
|  |     if (!isDragging) return; | ||||||
|  |  | ||||||
|  |     if (e.shiftKey) { | ||||||
|  |       const center = screenCenter ?? { x: 0, y: 0 }; | ||||||
|  |       const startAngle = Math.atan2( | ||||||
|  |         startMousePosition.y - center.y, | ||||||
|  |         startMousePosition.x - center.x | ||||||
|  |       ); | ||||||
|  |       const currentAngle = Math.atan2( | ||||||
|  |         e.globalY - center.y, | ||||||
|  |         e.globalX - center.x | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       // Calculate rotation difference in radians | ||||||
|  |       const rotationDiff = currentAngle - startAngle; | ||||||
|  |  | ||||||
|  |       // Update rotation | ||||||
|  |       setRotation(startRotation + rotationDiff); | ||||||
|  |  | ||||||
|  |       const cosDelta = Math.cos(rotationDiff); | ||||||
|  |       const sinDelta = Math.sin(rotationDiff); | ||||||
|  |  | ||||||
|  |       setPosition({ | ||||||
|  |         x: | ||||||
|  |           center.x * (1 - cosDelta) + | ||||||
|  |           startPosition.x * cosDelta + | ||||||
|  |           (center.y - startPosition.y) * sinDelta, | ||||||
|  |         y: | ||||||
|  |           center.y * (1 - cosDelta) + | ||||||
|  |           startPosition.y * cosDelta + | ||||||
|  |           (startPosition.x - center.x) * sinDelta, | ||||||
|  |       }); | ||||||
|  |     } else { | ||||||
|  |       setRotation(startRotation); | ||||||
|  |       setPosition({ | ||||||
|  |         x: startPosition.x - startMousePosition.x + e.globalX, | ||||||
|  |         y: startPosition.y - startMousePosition.y + e.globalY, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |     e.stopPropagation(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handlePointerUp = (e: FederatedMouseEvent) => { | ||||||
|  |     setIsDragging(false); | ||||||
|  |     // Сбрасываем флаг взаимодействия через небольшую задержку | ||||||
|  |     // чтобы избежать немедленного срабатывания useEffect | ||||||
|  |     setTimeout(() => { | ||||||
|  |       setIsUserInteracting(false); | ||||||
|  |     }, 100); | ||||||
|  |     e.stopPropagation(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleWheel = (e: FederatedWheelEvent) => { | ||||||
|  |     e.stopPropagation(); | ||||||
|  |     setIsUserInteracting(true); // Устанавливаем флаг при зуме | ||||||
|  |  | ||||||
|  |     // Get mouse position relative to canvas | ||||||
|  |     const mouseX = e.globalX - position.x; | ||||||
|  |     const mouseY = e.globalY - position.y; | ||||||
|  |  | ||||||
|  |     // Calculate new scale | ||||||
|  |     const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; | ||||||
|  |     const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; | ||||||
|  |  | ||||||
|  |     const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in | ||||||
|  |     const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); | ||||||
|  |     const actualZoomFactor = newScale / scale; | ||||||
|  |  | ||||||
|  |     if (scale === newScale) { | ||||||
|  |       // Сбрасываем флаг, если зум не изменился | ||||||
|  |       setTimeout(() => { | ||||||
|  |         setIsUserInteracting(false); | ||||||
|  |       }, 100); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Update position to zoom towards mouse cursor | ||||||
|  |     setPosition({ | ||||||
|  |       x: position.x + mouseX * (1 - actualZoomFactor), | ||||||
|  |       y: position.y + mouseY * (1 - actualZoomFactor), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     setScale(newScale); | ||||||
|  |  | ||||||
|  |     // Сбрасываем флаг взаимодействия через задержку | ||||||
|  |     setTimeout(() => { | ||||||
|  |       setIsUserInteracting(false); | ||||||
|  |     }, 100); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     applicationRef?.app.render(); | ||||||
|  |     console.log(position, scale, rotation); | ||||||
|  |   }, [position, scale, rotation]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <ErrorBoundary> | ||||||
|  |       {applicationRef?.app && ( | ||||||
|  |         <pixiGraphics | ||||||
|  |           draw={(g) => { | ||||||
|  |             const canvas = applicationRef.app.canvas; | ||||||
|  |             g.clear(); | ||||||
|  |             g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0); | ||||||
|  |             g.fill("#111"); | ||||||
|  |           }} | ||||||
|  |           eventMode={"static"} | ||||||
|  |           interactive | ||||||
|  |           onPointerDown={handlePointerDown} | ||||||
|  |           onGlobalPointerMove={handlePointerMove} | ||||||
|  |           onPointerUp={handlePointerUp} | ||||||
|  |           onPointerUpOutside={handlePointerUp} | ||||||
|  |           onWheel={handleWheel} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |       <pixiContainer | ||||||
|  |         x={position.x} | ||||||
|  |         y={position.y} | ||||||
|  |         scale={scale} | ||||||
|  |         rotation={rotation} | ||||||
|  |       > | ||||||
|  |         {children} | ||||||
|  |       </pixiContainer> | ||||||
|  |       {/* Show center of the screen. | ||||||
|  |       <pixiGraphics | ||||||
|  |         eventMode="none" | ||||||
|  |         draw={(g) => { | ||||||
|  |           g.clear(); | ||||||
|  |           const center = screenCenter ?? {x: 0, y: 0}; | ||||||
|  |           g.circle(center.x, center.y, 1); | ||||||
|  |           g.fill("#fff"); | ||||||
|  |         }} | ||||||
|  |       /> */} | ||||||
|  |     </ErrorBoundary> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								src/pages/route-preview/LeftSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/pages/route-preview/LeftSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | import { Stack, Typography, Button } from "@mui/material"; | ||||||
|  |  | ||||||
|  | import { useNavigate, useNavigationType } from "react-router"; | ||||||
|  |  | ||||||
|  | export function LeftSidebar() { | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const navigationType = useNavigationType(); // PUSH, POP, REPLACE | ||||||
|  |  | ||||||
|  |   const handleBack = () => { | ||||||
|  |     if (navigationType === "PUSH") { | ||||||
|  |       navigate(-1); | ||||||
|  |     } else { | ||||||
|  |       navigate("/route"); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Stack direction="column" width="300px" p={2} bgcolor="primary.main"> | ||||||
|  |       <button | ||||||
|  |         onClick={handleBack} | ||||||
|  |         type="button" | ||||||
|  |         style={{ | ||||||
|  |           display: "flex", | ||||||
|  |           justifyContent: "center", | ||||||
|  |           alignItems: "center", | ||||||
|  |           gap: 10, | ||||||
|  |           color: "#fff", | ||||||
|  |           backgroundColor: "#222", | ||||||
|  |           borderRadius: 10, | ||||||
|  |           width: "100%", | ||||||
|  |           border: "none", | ||||||
|  |           cursor: "pointer", | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         <p>Назад</p> | ||||||
|  |       </button> | ||||||
|  |  | ||||||
|  |       <Stack | ||||||
|  |         direction="column" | ||||||
|  |         alignItems="center" | ||||||
|  |         justifyContent="center" | ||||||
|  |         my={10} | ||||||
|  |       > | ||||||
|  |         <img src={"/Emblem.svg"} alt="logo" width={100} height={100} /> | ||||||
|  |         <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> | ||||||
|  |           При поддержке Правительства Санкт-Петербурга | ||||||
|  |         </Typography> | ||||||
|  |       </Stack> | ||||||
|  |  | ||||||
|  |       <Stack | ||||||
|  |         direction="column" | ||||||
|  |         alignItems="center" | ||||||
|  |         justifyContent="center" | ||||||
|  |         my={10} | ||||||
|  |         spacing={2} | ||||||
|  |       > | ||||||
|  |         <Button variant="outlined" color="warning" fullWidth> | ||||||
|  |           Достопримечательности | ||||||
|  |         </Button> | ||||||
|  |         <Button variant="outlined" color="warning" fullWidth> | ||||||
|  |           Остановки | ||||||
|  |         </Button> | ||||||
|  |       </Stack> | ||||||
|  |  | ||||||
|  |       <Stack | ||||||
|  |         direction="column" | ||||||
|  |         alignItems="center" | ||||||
|  |         justifyContent="center" | ||||||
|  |         my={10} | ||||||
|  |       > | ||||||
|  |         <img | ||||||
|  |           src={"/GET.png"} | ||||||
|  |           alt="logo" | ||||||
|  |           width="80%" | ||||||
|  |           style={{ margin: "0 auto" }} | ||||||
|  |         /> | ||||||
|  |       </Stack> | ||||||
|  |  | ||||||
|  |       <Typography | ||||||
|  |         variant="h6" | ||||||
|  |         textAlign="center" | ||||||
|  |         mt="auto" | ||||||
|  |         sx={{ color: "#fff" }} | ||||||
|  |       > | ||||||
|  |         #ВсемПоПути | ||||||
|  |       </Typography> | ||||||
|  |     </Stack> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										311
									
								
								src/pages/route-preview/MapDataContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										311
									
								
								src/pages/route-preview/MapDataContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,311 @@ | |||||||
|  | import { useApiUrl } from "@refinedev/core"; | ||||||
|  | import { useParams } from "react-router"; | ||||||
|  | import { | ||||||
|  |   createContext, | ||||||
|  |   ReactNode, | ||||||
|  |   useContext, | ||||||
|  |   useEffect, | ||||||
|  |   useMemo, | ||||||
|  |   useState, | ||||||
|  | } from "react"; | ||||||
|  | import { | ||||||
|  |   RouteData, | ||||||
|  |   SightData, | ||||||
|  |   SightPatchData, | ||||||
|  |   StationData, | ||||||
|  |   StationPatchData, | ||||||
|  | } from "./types"; | ||||||
|  | import { axiosInstance } from "../../providers/data"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@/store/LanguageStore"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { axiosInstanceForGet } from "@/providers/data"; | ||||||
|  |  | ||||||
|  | const MapDataContext = createContext<{ | ||||||
|  |   originalRouteData?: RouteData; | ||||||
|  |   originalStationData?: StationData[]; | ||||||
|  |   originalSightData?: SightData[]; | ||||||
|  |   routeData?: RouteData; | ||||||
|  |   stationData?: StationDataWithLanguage; | ||||||
|  |   sightData?: SightData[]; | ||||||
|  |  | ||||||
|  |   isRouteLoading: boolean; | ||||||
|  |   isStationLoading: boolean; | ||||||
|  |   isSightLoading: boolean; | ||||||
|  |   setScaleRange: (min: number, max: number) => void; | ||||||
|  |   setMapRotation: (rotation: number) => void; | ||||||
|  |   setMapCenter: (x: number, y: number) => void; | ||||||
|  |   setStationOffset: (stationId: number, x: number, y: number) => void; | ||||||
|  |   setSightCoordinates: ( | ||||||
|  |     sightId: number, | ||||||
|  |     latitude: number, | ||||||
|  |     longitude: number | ||||||
|  |   ) => void; | ||||||
|  |   saveChanges: () => void; | ||||||
|  | }>({ | ||||||
|  |   originalRouteData: undefined, | ||||||
|  |   originalStationData: undefined, | ||||||
|  |   originalSightData: undefined, | ||||||
|  |   routeData: undefined, | ||||||
|  |   stationData: undefined, | ||||||
|  |   sightData: undefined, | ||||||
|  |  | ||||||
|  |   isRouteLoading: true, | ||||||
|  |   isStationLoading: true, | ||||||
|  |   isSightLoading: true, | ||||||
|  |   setScaleRange: () => {}, | ||||||
|  |   setMapRotation: () => {}, | ||||||
|  |   setMapCenter: () => {}, | ||||||
|  |   setStationOffset: () => {}, | ||||||
|  |   setSightCoordinates: () => {}, | ||||||
|  |   saveChanges: () => {}, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | type StationDataWithLanguage = { | ||||||
|  |   [key: string]: StationData[]; | ||||||
|  | }; | ||||||
|  | export const MapDataProvider = observer( | ||||||
|  |   ({ children }: Readonly<{ children: ReactNode }>) => { | ||||||
|  |     const { id: routeId } = useParams<{ id: string }>(); | ||||||
|  |     const apiUrl = useApiUrl(); | ||||||
|  |  | ||||||
|  |     const [originalRouteData, setOriginalRouteData] = useState<RouteData>(); | ||||||
|  |     const [originalStationData, setOriginalStationData] = | ||||||
|  |       useState<StationData[]>(); | ||||||
|  |     const [originalSightData, setOriginalSightData] = useState<SightData[]>(); | ||||||
|  |  | ||||||
|  |     const [routeData, setRouteData] = useState<RouteData>(); | ||||||
|  |     const [stationData, setStationData] = useState<StationDataWithLanguage>({ | ||||||
|  |       RU: [], | ||||||
|  |       EN: [], | ||||||
|  |       ZH: [], | ||||||
|  |     }); | ||||||
|  |     const [sightData, setSightData] = useState<SightData[]>(); | ||||||
|  |  | ||||||
|  |     const [routeChanges, setRouteChanges] = useState<Partial<RouteData>>({}); | ||||||
|  |     const [stationChanges, setStationChanges] = useState<StationPatchData[]>( | ||||||
|  |       [] | ||||||
|  |     ); | ||||||
|  |     const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]); | ||||||
|  |     const { language } = languageStore; | ||||||
|  |  | ||||||
|  |     const [isRouteLoading, setIsRouteLoading] = useState(true); | ||||||
|  |     const [isStationLoading, setIsStationLoading] = useState(true); | ||||||
|  |     const [isSightLoading, setIsSightLoading] = useState(true); | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |       const fetchData = async () => { | ||||||
|  |         try { | ||||||
|  |           setIsRouteLoading(true); | ||||||
|  |           setIsStationLoading(true); | ||||||
|  |           setIsSightLoading(true); | ||||||
|  |  | ||||||
|  |           const [ | ||||||
|  |             routeResponse, | ||||||
|  |             ruStationResponse, | ||||||
|  |             enStationResponse, | ||||||
|  |             zhStationResponse, | ||||||
|  |             sightResponse, | ||||||
|  |           ] = await Promise.all([ | ||||||
|  |             axiosInstanceForGet(language).get(`/route/${routeId}`), | ||||||
|  |             axiosInstanceForGet("ru").get(`/route/${routeId}/station`), | ||||||
|  |             axiosInstanceForGet("en").get(`/route/${routeId}/station`), | ||||||
|  |             axiosInstanceForGet("zh").get(`/route/${routeId}/station`), | ||||||
|  |             axiosInstanceForGet(language).get(`/route/${routeId}/sight`), | ||||||
|  |           ]); | ||||||
|  |  | ||||||
|  |           setOriginalRouteData(routeResponse.data as RouteData); | ||||||
|  |           setOriginalStationData(ruStationResponse.data as StationData[]); | ||||||
|  |           setStationData({ | ||||||
|  |             ru: ruStationResponse.data as StationData[], | ||||||
|  |             en: enStationResponse.data as StationData[], | ||||||
|  |             zh: zhStationResponse.data as StationData[], | ||||||
|  |           }); | ||||||
|  |           setOriginalSightData(sightResponse.data as SightData[]); | ||||||
|  |  | ||||||
|  |           setIsRouteLoading(false); | ||||||
|  |           setIsStationLoading(false); | ||||||
|  |           setIsSightLoading(false); | ||||||
|  |         } catch (error) { | ||||||
|  |           console.error("Error fetching data:", error); | ||||||
|  |           setIsRouteLoading(false); | ||||||
|  |           setIsStationLoading(false); | ||||||
|  |           setIsSightLoading(false); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       fetchData(); | ||||||
|  |     }, [routeId]); | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |       // combine changes with original data | ||||||
|  |       if (originalRouteData) | ||||||
|  |         setRouteData({ ...originalRouteData, ...routeChanges }); | ||||||
|  |       if (originalSightData) setSightData(originalSightData); | ||||||
|  |     }, [ | ||||||
|  |       originalRouteData, | ||||||
|  |       originalSightData, | ||||||
|  |       routeChanges, | ||||||
|  |       stationChanges, | ||||||
|  |       sightChanges, | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|  |     function setScaleRange(min: number, max: number) { | ||||||
|  |       setRouteChanges((prev) => { | ||||||
|  |         return { ...prev, scale_min: min, scale_max: max }; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setMapRotation(rotation: number) { | ||||||
|  |       setRouteChanges((prev) => { | ||||||
|  |         return { ...prev, rotate: rotation }; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setMapCenter(x: number, y: number) { | ||||||
|  |       setRouteChanges((prev) => { | ||||||
|  |         return { ...prev, center_latitude: x, center_longitude: y }; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function saveChanges() { | ||||||
|  |       await axiosInstance.patch(`/route/${routeId}`, routeData); | ||||||
|  |       await saveStationChanges(); | ||||||
|  |       await saveSightChanges(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function saveStationChanges() { | ||||||
|  |       for (const station of stationChanges) { | ||||||
|  |         const response = await axiosInstance.patch( | ||||||
|  |           `/route/${routeId}/station`, | ||||||
|  |           station | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function saveSightChanges() { | ||||||
|  |       console.log("sightChanges", sightChanges); | ||||||
|  |       for (const sight of sightChanges) { | ||||||
|  |         const response = await axiosInstance.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; | ||||||
|  |  | ||||||
|  |           return prev.map((station) => { | ||||||
|  |             if (station.station_id === stationId) { | ||||||
|  |               return found; | ||||||
|  |             } | ||||||
|  |             return station; | ||||||
|  |           }); | ||||||
|  |         } else { | ||||||
|  |           const foundStation = stationData.ru?.find( | ||||||
|  |             (station) => station.id === stationId | ||||||
|  |           ); | ||||||
|  |           if (foundStation) { | ||||||
|  |             return [ | ||||||
|  |               ...prev, | ||||||
|  |               { | ||||||
|  |                 station_id: stationId, | ||||||
|  |                 offset_x: x, | ||||||
|  |                 offset_y: y, | ||||||
|  |                 transfers: foundStation.transfers, | ||||||
|  |               }, | ||||||
|  |             ]; | ||||||
|  |           } | ||||||
|  |           return prev; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setSightCoordinates( | ||||||
|  |       sightId: number, | ||||||
|  |       latitude: number, | ||||||
|  |       longitude: number | ||||||
|  |     ) { | ||||||
|  |       setSightChanges((prev) => { | ||||||
|  |         let found = prev.find((sight) => sight.sight_id === sightId); | ||||||
|  |         if (found) { | ||||||
|  |           found.latitude = latitude; | ||||||
|  |           found.longitude = longitude; | ||||||
|  |  | ||||||
|  |           return prev.map((sight) => { | ||||||
|  |             if (sight.sight_id === sightId) { | ||||||
|  |               return found; | ||||||
|  |             } | ||||||
|  |             return sight; | ||||||
|  |           }); | ||||||
|  |         } else { | ||||||
|  |           const foundSight = sightData?.find((sight) => sight.id === sightId); | ||||||
|  |           if (foundSight) { | ||||||
|  |             return [ | ||||||
|  |               ...prev, | ||||||
|  |               { | ||||||
|  |                 sight_id: sightId, | ||||||
|  |                 latitude, | ||||||
|  |                 longitude, | ||||||
|  |               }, | ||||||
|  |             ]; | ||||||
|  |           } | ||||||
|  |           return prev; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |       console.log("sightChanges", sightChanges); | ||||||
|  |     }, [sightChanges]); | ||||||
|  |  | ||||||
|  |     const value = useMemo( | ||||||
|  |       () => ({ | ||||||
|  |         originalRouteData, | ||||||
|  |         originalStationData, | ||||||
|  |         originalSightData, | ||||||
|  |         routeData, | ||||||
|  |         stationData, | ||||||
|  |         sightData, | ||||||
|  |         isRouteLoading, | ||||||
|  |         isStationLoading, | ||||||
|  |         isSightLoading, | ||||||
|  |         setScaleRange, | ||||||
|  |         setMapRotation, | ||||||
|  |         setMapCenter, | ||||||
|  |         saveChanges, | ||||||
|  |         setStationOffset, | ||||||
|  |         setSightCoordinates, | ||||||
|  |       }), | ||||||
|  |       [ | ||||||
|  |         originalRouteData, | ||||||
|  |         originalStationData, | ||||||
|  |         originalSightData, | ||||||
|  |         routeData, | ||||||
|  |         stationData, | ||||||
|  |         sightData, | ||||||
|  |         isRouteLoading, | ||||||
|  |         isStationLoading, | ||||||
|  |         isSightLoading, | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <MapDataContext.Provider value={value}> | ||||||
|  |         {children} | ||||||
|  |       </MapDataContext.Provider> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const useMapData = () => { | ||||||
|  |   const context = useContext(MapDataContext); | ||||||
|  |   if (!context) { | ||||||
|  |     throw new Error("useMapData must be used within a MapDataProvider"); | ||||||
|  |   } | ||||||
|  |   return context; | ||||||
|  | }; | ||||||
							
								
								
									
										232
									
								
								src/pages/route-preview/RightSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								src/pages/route-preview/RightSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | |||||||
|  | import { Button, Stack, TextField, Typography } from "@mui/material"; | ||||||
|  | import { useMapData } from "./MapDataContext"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { useTransform } from "./TransformContext"; | ||||||
|  | import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||||
|  |  | ||||||
|  | export function RightSidebar() { | ||||||
|  |   const { | ||||||
|  |     routeData, | ||||||
|  |     setScaleRange, | ||||||
|  |     saveChanges, | ||||||
|  |     originalRouteData, | ||||||
|  |     setMapRotation, | ||||||
|  |     setMapCenter, | ||||||
|  |   } = useMapData(); | ||||||
|  |   const { | ||||||
|  |     rotation, | ||||||
|  |     position, | ||||||
|  |     screenToLocal, | ||||||
|  |     screenCenter, | ||||||
|  |     rotateToAngle, | ||||||
|  |     setTransform, | ||||||
|  |   } = useTransform(); | ||||||
|  |   const [minScale, setMinScale] = useState<number>(1); | ||||||
|  |   const [maxScale, setMaxScale] = useState<number>(10); | ||||||
|  |   const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({ | ||||||
|  |     x: 0, | ||||||
|  |     y: 0, | ||||||
|  |   }); | ||||||
|  |   const [rotationDegrees, setRotationDegrees] = useState<number>(0); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (originalRouteData) { | ||||||
|  |       setMinScale(originalRouteData.scale_min ?? 1); | ||||||
|  |       setMaxScale(originalRouteData.scale_max ?? 10); | ||||||
|  |       setRotationDegrees(originalRouteData.rotate ?? 0); | ||||||
|  |       setLocalCenter({ | ||||||
|  |         x: originalRouteData.center_latitude ?? 0, | ||||||
|  |         y: originalRouteData.center_longitude ?? 0, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   }, [originalRouteData]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (minScale && maxScale) { | ||||||
|  |       setScaleRange(minScale, maxScale); | ||||||
|  |     } | ||||||
|  |   }, [minScale, maxScale]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setRotationDegrees( | ||||||
|  |       ((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]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setMapCenter(localCenter.x, localCenter.y); | ||||||
|  |   }, [localCenter]); | ||||||
|  |  | ||||||
|  |   function setRotationFromDegrees(degrees: number) { | ||||||
|  |     rotateToAngle((degrees * Math.PI) / 180); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function pan({ x, y }: { x: number; y: number }) { | ||||||
|  |     const coordinates = coordinatesToLocal(x, y); | ||||||
|  |     setTransform(coordinates.x, coordinates.y); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!routeData) { | ||||||
|  |     console.error("routeData is null"); | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Stack | ||||||
|  |       position="absolute" | ||||||
|  |       right={8} | ||||||
|  |       top={8} | ||||||
|  |       bottom={8} | ||||||
|  |       p={2} | ||||||
|  |       gap={1} | ||||||
|  |       minWidth="400px" | ||||||
|  |       bgcolor="primary.main" | ||||||
|  |       border="1px solid #e0e0e0" | ||||||
|  |       borderRadius={2} | ||||||
|  |     > | ||||||
|  |       <Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center"> | ||||||
|  |         Детали о достопримечательностях | ||||||
|  |       </Typography> | ||||||
|  |  | ||||||
|  |       <Stack spacing={2} direction="row" alignItems="center"> | ||||||
|  |         <TextField | ||||||
|  |           type="number" | ||||||
|  |           label="Минимальный масштаб" | ||||||
|  |           variant="filled" | ||||||
|  |           value={minScale} | ||||||
|  |           onChange={(e) => setMinScale(Number(e.target.value))} | ||||||
|  |           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||||
|  |           sx={{ | ||||||
|  |             "& .MuiInputLabel-root": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |             "& .MuiInputBase-input": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |           slotProps={{ | ||||||
|  |             input: { | ||||||
|  |               min: 0.1, | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |         <TextField | ||||||
|  |           type="number" | ||||||
|  |           label="Максимальный масштаб" | ||||||
|  |           variant="filled" | ||||||
|  |           value={maxScale} | ||||||
|  |           onChange={(e) => setMaxScale(Number(e.target.value))} | ||||||
|  |           style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }} | ||||||
|  |           sx={{ | ||||||
|  |             "& .MuiInputLabel-root": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |             "& .MuiInputBase-input": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |           slotProps={{ | ||||||
|  |             input: { | ||||||
|  |               min: 0.1, | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       </Stack> | ||||||
|  |  | ||||||
|  |       <TextField | ||||||
|  |         type="number" | ||||||
|  |         label="Поворот (в градусах)" | ||||||
|  |         variant="filled" | ||||||
|  |         value={rotationDegrees} | ||||||
|  |         onChange={(e) => { | ||||||
|  |           const value = Number(e.target.value); | ||||||
|  |           if (!isNaN(value)) { | ||||||
|  |             setRotationFromDegrees(value); | ||||||
|  |           } | ||||||
|  |         }} | ||||||
|  |         onKeyDown={(e) => { | ||||||
|  |           if (e.key === "Enter") { | ||||||
|  |             e.currentTarget.blur(); | ||||||
|  |           } | ||||||
|  |         }} | ||||||
|  |         style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||||
|  |         sx={{ | ||||||
|  |           "& .MuiInputLabel-root": { | ||||||
|  |             color: "#fff", | ||||||
|  |           }, | ||||||
|  |           "& .MuiInputBase-input": { | ||||||
|  |             color: "#fff", | ||||||
|  |           }, | ||||||
|  |         }} | ||||||
|  |         slotProps={{ | ||||||
|  |           input: { | ||||||
|  |             min: 0, | ||||||
|  |             max: 360, | ||||||
|  |           }, | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |  | ||||||
|  |       <Stack direction="row" spacing={2}> | ||||||
|  |         <TextField | ||||||
|  |           type="number" | ||||||
|  |           label="Центр карты, широта" | ||||||
|  |           variant="filled" | ||||||
|  |           value={Math.round(localCenter.x * 100000) / 100000} | ||||||
|  |           onChange={(e) => { | ||||||
|  |             setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); | ||||||
|  |             pan({ x: Number(e.target.value), y: localCenter.y }); | ||||||
|  |           }} | ||||||
|  |           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||||
|  |           sx={{ | ||||||
|  |             "& .MuiInputLabel-root": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |             "& .MuiInputBase-input": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |         <TextField | ||||||
|  |           type="number" | ||||||
|  |           label="Центр карты, высота" | ||||||
|  |           variant="filled" | ||||||
|  |           value={Math.round(localCenter.y * 100000) / 100000} | ||||||
|  |           onChange={(e) => { | ||||||
|  |             setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); | ||||||
|  |             pan({ x: localCenter.x, y: Number(e.target.value) }); | ||||||
|  |           }} | ||||||
|  |           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||||
|  |           sx={{ | ||||||
|  |             "& .MuiInputLabel-root": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |             "& .MuiInputBase-input": { | ||||||
|  |               color: "#fff", | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       </Stack> | ||||||
|  |  | ||||||
|  |       <Button | ||||||
|  |         variant="contained" | ||||||
|  |         color="secondary" | ||||||
|  |         sx={{ mt: 2 }} | ||||||
|  |         onClick={() => { | ||||||
|  |           saveChanges(); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         Сохранить изменения | ||||||
|  |       </Button> | ||||||
|  |     </Stack> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										119
									
								
								src/pages/route-preview/Sight.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/pages/route-preview/Sight.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | |||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { useTransform } from "./TransformContext"; | ||||||
|  | import { SightData } from "./types"; | ||||||
|  | import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js"; | ||||||
|  | import { COLORS } from "../../contexts/color-mode/theme"; | ||||||
|  | import { SIGHT_SIZE, UP_SCALE } from "./Constants"; | ||||||
|  | import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||||
|  | import { useMapData } from "./MapDataContext"; | ||||||
|  |  | ||||||
|  | interface SightProps { | ||||||
|  | 	sight: SightData; | ||||||
|  | 	id: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function Sight({ | ||||||
|  | 	sight, id | ||||||
|  | }: Readonly<SightProps>) { | ||||||
|  | 	const { rotation, scale } = useTransform(); | ||||||
|  | 	const { setSightCoordinates } = useMapData(); | ||||||
|  |  | ||||||
|  | 	const [position, setPosition] = useState(coordinatesToLocal(sight.latitude, sight.longitude)); | ||||||
|  | 	const [isDragging, setIsDragging] = useState(false); | ||||||
|  |     const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||||
|  |     const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||||
|  |  | ||||||
|  |     const handlePointerDown = (e: FederatedMouseEvent) => { | ||||||
|  |         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) / scale / UP_SCALE; | ||||||
|  | 		const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE; | ||||||
|  | 		const cos = Math.cos(rotation); | ||||||
|  | 		const sin = Math.sin(rotation); | ||||||
|  | 		const newPosition = { | ||||||
|  | 			x: startPosition.x + dx * cos + dy * sin, | ||||||
|  | 			y: startPosition.y - dx * sin + dy * cos | ||||||
|  | 		}; | ||||||
|  | 		setPosition(newPosition); | ||||||
|  | 		const coordinates = localToCoordinates(newPosition.x, newPosition.y); | ||||||
|  | 		setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude); | ||||||
|  |         e.stopPropagation(); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const handlePointerUp = (e: FederatedMouseEvent) => { | ||||||
|  |         setIsDragging(false); | ||||||
|  |         e.stopPropagation(); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  | 	const [texture, setTexture] = useState(Texture.EMPTY); | ||||||
|  | 	useEffect(() => { | ||||||
|  |         if (texture === Texture.EMPTY) { | ||||||
|  |             Assets | ||||||
|  |                 .load('/SightIcon.png') | ||||||
|  |                 .then((result) => { | ||||||
|  |                     setTexture(result) | ||||||
|  |                 }); | ||||||
|  |         } | ||||||
|  |     }, [texture]); | ||||||
|  |  | ||||||
|  | 	function draw(g: Graphics) { | ||||||
|  | 		g.clear(); | ||||||
|  | 		g.circle(0, 0, 20); | ||||||
|  | 		g.fill({color: COLORS.primary}); // Fill circle with primary color | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if(!sight) { | ||||||
|  | 		console.error("sight is null"); | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	const coordinates = coordinatesToLocal(sight.latitude, sight.longitude); | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<pixiContainer rotation={-rotation} | ||||||
|  | 			eventMode='static' | ||||||
|  | 			interactive | ||||||
|  | 			onPointerDown={handlePointerDown} | ||||||
|  | 			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 | ||||||
|  | 		> | ||||||
|  | 			<pixiSprite | ||||||
|  | 				texture={texture}  | ||||||
|  | 				width={SIGHT_SIZE} | ||||||
|  | 				height={SIGHT_SIZE} | ||||||
|  | 			/> | ||||||
|  | 			<pixiGraphics | ||||||
|  | 				draw={draw} | ||||||
|  | 				x={SIGHT_SIZE} | ||||||
|  | 				y={0} | ||||||
|  | 			/> | ||||||
|  | 			<pixiText | ||||||
|  | 				text={`${id+1}`} | ||||||
|  | 				x={SIGHT_SIZE+1} | ||||||
|  | 				y={0} | ||||||
|  | 				anchor={0.5} | ||||||
|  | 				 | ||||||
|  | 				style={{ | ||||||
|  | 					fontSize: 24, | ||||||
|  | 					fontWeight: 'bold', | ||||||
|  | 					fill: "#ffffff", | ||||||
|  | 				}} | ||||||
|  | 			/> | ||||||
|  | 		</pixiContainer> | ||||||
|  | 	); | ||||||
|  | } | ||||||
							
								
								
									
										148
									
								
								src/pages/route-preview/Station.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								src/pages/route-preview/Station.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | |||||||
|  | import { FederatedMouseEvent, Graphics } from "pixi.js"; | ||||||
|  | import { | ||||||
|  |   BACKGROUND_COLOR, | ||||||
|  |   PATH_COLOR, | ||||||
|  |   STATION_RADIUS, | ||||||
|  |   STATION_OUTLINE_WIDTH, | ||||||
|  |   UP_SCALE, | ||||||
|  | } from "./Constants"; | ||||||
|  | import { useTransform } from "./TransformContext"; | ||||||
|  | import { useCallback, useEffect, useRef, useState } from "react"; | ||||||
|  | import { StationData } from "./types"; | ||||||
|  | import { useMapData } from "./MapDataContext"; | ||||||
|  | import { coordinatesToLocal } from "./utils"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore } from "@stores"; | ||||||
|  |  | ||||||
|  | interface StationProps { | ||||||
|  |   station: StationData; | ||||||
|  |   ruLabel: string | null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const Station = observer( | ||||||
|  |   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||||
|  |     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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const StationLabel = observer( | ||||||
|  |   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||||
|  |     const { language } = languageStore; | ||||||
|  |     const { rotation, scale } = useTransform(); | ||||||
|  |     const { setStationOffset } = useMapData(); | ||||||
|  |  | ||||||
|  |     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, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | ); | ||||||
							
								
								
									
										204
									
								
								src/pages/route-preview/TransformContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/pages/route-preview/TransformContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,204 @@ | |||||||
|  | import { | ||||||
|  |   createContext, | ||||||
|  |   ReactNode, | ||||||
|  |   useContext, | ||||||
|  |   useMemo, | ||||||
|  |   useState, | ||||||
|  |   useCallback, | ||||||
|  | } from "react"; | ||||||
|  | import { SCALE_FACTOR, UP_SCALE } from "./Constants"; | ||||||
|  |  | ||||||
|  | const TransformContext = createContext<{ | ||||||
|  |   position: { x: number; y: number }; | ||||||
|  |   scale: number; | ||||||
|  |   rotation: number; | ||||||
|  |   screenCenter?: { x: number; y: number }; | ||||||
|  |  | ||||||
|  |   setPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>; | ||||||
|  |   setScale: React.Dispatch<React.SetStateAction<number>>; | ||||||
|  |   setRotation: React.Dispatch<React.SetStateAction<number>>; | ||||||
|  |   screenToLocal: (x: number, y: number) => { x: number; y: number }; | ||||||
|  |   localToScreen: (x: number, y: number) => { x: number; y: number }; | ||||||
|  |   rotateToAngle: (to: number, fromPosition?: { x: number; y: number }) => void; | ||||||
|  |   setTransform: ( | ||||||
|  |     latitude: number, | ||||||
|  |     longitude: number, | ||||||
|  |     rotationDegrees?: number, | ||||||
|  |     scale?: number | ||||||
|  |   ) => void; | ||||||
|  |   setScreenCenter: React.Dispatch< | ||||||
|  |     React.SetStateAction<{ x: number; y: number } | undefined> | ||||||
|  |   >; | ||||||
|  | }>({ | ||||||
|  |   position: { x: 0, y: 0 }, | ||||||
|  |   scale: 1, | ||||||
|  |   rotation: 0, | ||||||
|  |   screenCenter: undefined, | ||||||
|  |   setPosition: () => {}, | ||||||
|  |   setScale: () => {}, | ||||||
|  |   setRotation: () => {}, | ||||||
|  |   screenToLocal: () => ({ x: 0, y: 0 }), | ||||||
|  |   localToScreen: () => ({ x: 0, y: 0 }), | ||||||
|  |   rotateToAngle: () => {}, | ||||||
|  |   setTransform: () => {}, | ||||||
|  |   setScreenCenter: () => {}, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Provider component | ||||||
|  | export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||||
|  |   const [position, setPosition] = useState({ x: 0, y: 0 }); | ||||||
|  |   const [scale, setScale] = useState(1); | ||||||
|  |   const [rotation, setRotation] = useState(0); | ||||||
|  |   const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>(); | ||||||
|  |  | ||||||
|  |   const screenToLocal = useCallback( | ||||||
|  |     (screenX: number, screenY: number) => { | ||||||
|  |       // Translate point relative to current pan position | ||||||
|  |       const translatedX = (screenX - position.x) / scale; | ||||||
|  |       const translatedY = (screenY - position.y) / scale; | ||||||
|  |  | ||||||
|  |       // Rotate point around center | ||||||
|  |       const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform | ||||||
|  |       const sinRotation = Math.sin(-rotation); | ||||||
|  |       const rotatedX = translatedX * cosRotation - translatedY * sinRotation; | ||||||
|  |       const rotatedY = translatedX * sinRotation + translatedY * cosRotation; | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         x: rotatedX / UP_SCALE, | ||||||
|  |         y: rotatedY / UP_SCALE, | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     [position.x, position.y, scale, rotation] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   // Inverse of screenToLocal | ||||||
|  |   const localToScreen = useCallback( | ||||||
|  |     (localX: number, localY: number) => { | ||||||
|  |       const upscaledX = localX * UP_SCALE; | ||||||
|  |       const upscaledY = localY * UP_SCALE; | ||||||
|  |  | ||||||
|  |       const cosRotation = Math.cos(rotation); | ||||||
|  |       const sinRotation = Math.sin(rotation); | ||||||
|  |       const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation; | ||||||
|  |       const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation; | ||||||
|  |  | ||||||
|  |       const translatedX = rotatedX * scale + position.x; | ||||||
|  |       const translatedY = rotatedY * scale + position.y; | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         x: translatedX, | ||||||
|  |         y: translatedY, | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     [position.x, position.y, scale, rotation] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const rotateToAngle = useCallback( | ||||||
|  |     (to: number, fromPosition?: { x: number; y: number }) => { | ||||||
|  |       const rotationDiff = to - rotation; | ||||||
|  |  | ||||||
|  |       const center = screenCenter ?? { x: 0, y: 0 }; | ||||||
|  |       const cosDelta = Math.cos(rotationDiff); | ||||||
|  |       const sinDelta = Math.sin(rotationDiff); | ||||||
|  |  | ||||||
|  |       const currentFromPosition = fromPosition ?? position; | ||||||
|  |  | ||||||
|  |       const newPosition = { | ||||||
|  |         x: | ||||||
|  |           center.x * (1 - cosDelta) + | ||||||
|  |           currentFromPosition.x * cosDelta + | ||||||
|  |           (center.y - currentFromPosition.y) * sinDelta, | ||||||
|  |         y: | ||||||
|  |           center.y * (1 - cosDelta) + | ||||||
|  |           currentFromPosition.y * cosDelta + | ||||||
|  |           (currentFromPosition.x - center.x) * sinDelta, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       // Update both rotation and position in a single batch to avoid stale closure | ||||||
|  |       setRotation(to); | ||||||
|  |       setPosition(newPosition); | ||||||
|  |     }, | ||||||
|  |     [rotation, position, screenCenter] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const setTransform = useCallback( | ||||||
|  |     ( | ||||||
|  |       latitude: number, | ||||||
|  |       longitude: number, | ||||||
|  |       rotationDegrees?: number, | ||||||
|  |       useScale?: number | ||||||
|  |     ) => { | ||||||
|  |       const selectedRotation = | ||||||
|  |         rotationDegrees !== undefined | ||||||
|  |           ? (rotationDegrees * Math.PI) / 180 | ||||||
|  |           : rotation; | ||||||
|  |       const selectedScale = | ||||||
|  |         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, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const cosRot = Math.cos(selectedRotation); | ||||||
|  |       const sinRot = Math.sin(selectedRotation); | ||||||
|  |  | ||||||
|  |       // Translate point relative to center, rotate, then translate back | ||||||
|  |       const dx = newPosition.x; | ||||||
|  |       const dy = newPosition.y; | ||||||
|  |       newPosition.x = dx * cosRot - dy * sinRot + center.x; | ||||||
|  |       newPosition.y = dx * sinRot + dy * cosRot + center.y; | ||||||
|  |  | ||||||
|  |       // Batch state updates to avoid intermediate renders | ||||||
|  |       setPosition(newPosition); | ||||||
|  |       setRotation(selectedRotation); | ||||||
|  |       setScale(selectedScale); | ||||||
|  |     }, | ||||||
|  |     [rotation, scale, screenCenter] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const value = useMemo( | ||||||
|  |     () => ({ | ||||||
|  |       position, | ||||||
|  |       scale, | ||||||
|  |       rotation, | ||||||
|  |       screenCenter, | ||||||
|  |       setPosition, | ||||||
|  |       setScale, | ||||||
|  |       setRotation, | ||||||
|  |       rotateToAngle, | ||||||
|  |       screenToLocal, | ||||||
|  |       localToScreen, | ||||||
|  |       setTransform, | ||||||
|  |       setScreenCenter, | ||||||
|  |     }), | ||||||
|  |     [ | ||||||
|  |       position, | ||||||
|  |       scale, | ||||||
|  |       rotation, | ||||||
|  |       screenCenter, | ||||||
|  |       rotateToAngle, | ||||||
|  |       screenToLocal, | ||||||
|  |       localToScreen, | ||||||
|  |       setTransform, | ||||||
|  |     ] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <TransformContext.Provider value={value}> | ||||||
|  |       {children} | ||||||
|  |     </TransformContext.Provider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Custom hook for easy access to transform values | ||||||
|  | export const useTransform = () => { | ||||||
|  |   const context = useContext(TransformContext); | ||||||
|  |   if (!context) { | ||||||
|  |     throw new Error("useTransform must be used within a TransformProvider"); | ||||||
|  |   } | ||||||
|  |   return context; | ||||||
|  | }; | ||||||
							
								
								
									
										34
									
								
								src/pages/route-preview/TravelPath.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/pages/route-preview/TravelPath.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | import { Graphics } from "pixi.js"; | ||||||
|  | import { useCallback } from "react"; | ||||||
|  | import { PATH_COLOR, PATH_WIDTH } from "./Constants"; | ||||||
|  | import { coordinatesToLocal } from "./utils"; | ||||||
|  |  | ||||||
|  | interface TravelPathProps { | ||||||
|  |   points: { x: number; y: number }[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function TravelPath({ points }: Readonly<TravelPathProps>) { | ||||||
|  |   const draw = useCallback( | ||||||
|  |     (g: Graphics) => { | ||||||
|  |       g.clear(); | ||||||
|  |       const coordStart = coordinatesToLocal(points[0].x, points[0].y); | ||||||
|  |       g.moveTo(coordStart.x, coordStart.y); | ||||||
|  |       for (let i = 1; i <= points.length - 1; i++) { | ||||||
|  |         const coordinates = coordinatesToLocal(points[i].x, points[i].y); | ||||||
|  |         g.lineTo(coordinates.x, coordinates.y); | ||||||
|  |       } | ||||||
|  |       g.stroke({ | ||||||
|  |         color: PATH_COLOR, | ||||||
|  |         width: PATH_WIDTH, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |     [points] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (points.length === 0) { | ||||||
|  |     console.error("points is empty"); | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return <pixiGraphics draw={draw} />; | ||||||
|  | } | ||||||
							
								
								
									
										43
									
								
								src/pages/route-preview/Widgets.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/pages/route-preview/Widgets.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | import { Stack, Typography } from "@mui/material"; | ||||||
|  |  | ||||||
|  | export function Widgets() { | ||||||
|  |   return ( | ||||||
|  |     <Stack | ||||||
|  |       direction="column" | ||||||
|  |       spacing={2} | ||||||
|  |       position="absolute" | ||||||
|  |       top={32} | ||||||
|  |       left={32} | ||||||
|  |       sx={{ pointerEvents: "none" }} | ||||||
|  |     > | ||||||
|  |       <Stack | ||||||
|  |         bgcolor="primary.main" | ||||||
|  |         width={361} | ||||||
|  |         height={96} | ||||||
|  |         p={2} | ||||||
|  |         m={2} | ||||||
|  |         borderRadius={2} | ||||||
|  |         alignItems="center" | ||||||
|  |         justifyContent="center" | ||||||
|  |       > | ||||||
|  |         <Typography variant="h6" sx={{ color: "#fff" }}> | ||||||
|  |           Станция | ||||||
|  |         </Typography> | ||||||
|  |       </Stack> | ||||||
|  |       <Stack | ||||||
|  |         bgcolor="primary.main" | ||||||
|  |         width={223} | ||||||
|  |         height={262} | ||||||
|  |         p={2} | ||||||
|  |         m={2} | ||||||
|  |         borderRadius={2} | ||||||
|  |         alignItems="center" | ||||||
|  |         justifyContent="center" | ||||||
|  |       > | ||||||
|  |         <Typography variant="h6" sx={{ color: "#fff" }}> | ||||||
|  |           Погода | ||||||
|  |         </Typography> | ||||||
|  |       </Stack> | ||||||
|  |     </Stack> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										176
									
								
								src/pages/route-preview/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/pages/route-preview/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | |||||||
|  | import { useRef, useEffect, useState } from "react"; | ||||||
|  |  | ||||||
|  | import { Application, ApplicationRef, extend } from "@pixi/react"; | ||||||
|  | import { | ||||||
|  |   Container, | ||||||
|  |   Graphics, | ||||||
|  |   Sprite, | ||||||
|  |   Texture, | ||||||
|  |   TilingSprite, | ||||||
|  |   Text, | ||||||
|  | } from "pixi.js"; | ||||||
|  | import { Stack } from "@mui/material"; | ||||||
|  | import { MapDataProvider, useMapData } from "./MapDataContext"; | ||||||
|  | import { TransformProvider, useTransform } from "./TransformContext"; | ||||||
|  | import { InfiniteCanvas } from "./InfiniteCanvas"; | ||||||
|  | import { Sight } from "./Sight"; | ||||||
|  | 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 { LanguageSwitch } from "@/components/LanguageSwitch"; | ||||||
|  | import { languageStore } from "@stores"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
|  | extend({ | ||||||
|  |   Container, | ||||||
|  |   Graphics, | ||||||
|  |   Sprite, | ||||||
|  |   Texture, | ||||||
|  |   TilingSprite, | ||||||
|  |   Text, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export const RoutePreview = () => { | ||||||
|  |   return ( | ||||||
|  |     <MapDataProvider> | ||||||
|  |       <TransformProvider> | ||||||
|  |         <Stack direction="row" height="100vh" width="100vw" overflow="hidden"> | ||||||
|  |           <div | ||||||
|  |             style={{ | ||||||
|  |               position: "absolute", | ||||||
|  |               top: 0, | ||||||
|  |               left: "50%", | ||||||
|  |               transform: "translateX(-50%)", | ||||||
|  |               zIndex: 1000, | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             <LanguageSwitch /> | ||||||
|  |           </div> | ||||||
|  |           <LeftSidebar /> | ||||||
|  |           <Stack direction="row" flex={1} position="relative" height="100%"> | ||||||
|  |             <Widgets /> | ||||||
|  |             <RouteMap /> | ||||||
|  |             <RightSidebar /> | ||||||
|  |           </Stack> | ||||||
|  |         </Stack> | ||||||
|  |       </TransformProvider> | ||||||
|  |     </MapDataProvider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const RouteMap = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|  |   const { setPosition, screenToLocal, setTransform, screenCenter } = | ||||||
|  |     useTransform(); | ||||||
|  |   const { routeData, stationData, sightData, originalRouteData } = useMapData(); | ||||||
|  |   console.log(stationData); | ||||||
|  |   const [points, setPoints] = useState<{ x: number; y: number }[]>([]); | ||||||
|  |   const [isSetup, setIsSetup] = useState(false); | ||||||
|  |  | ||||||
|  |   const parentRef = useRef<HTMLDivElement>(null); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (originalRouteData) { | ||||||
|  |       const path = originalRouteData?.path; | ||||||
|  |       const points = | ||||||
|  |         path?.map(([x, y]: [number, number]) => ({ | ||||||
|  |           x: x * UP_SCALE, | ||||||
|  |           y: y * UP_SCALE, | ||||||
|  |         })) ?? []; | ||||||
|  |       setPoints(points); | ||||||
|  |     } | ||||||
|  |   }, [originalRouteData]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isSetup || !screenCenter) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if ( | ||||||
|  |       originalRouteData?.center_latitude === | ||||||
|  |         originalRouteData?.center_longitude && | ||||||
|  |       originalRouteData?.center_latitude === 0 | ||||||
|  |     ) { | ||||||
|  |       if (points.length > 0) { | ||||||
|  |         let boundingBox = { | ||||||
|  |           from: { x: Infinity, y: Infinity }, | ||||||
|  |           to: { x: -Infinity, y: -Infinity }, | ||||||
|  |         }; | ||||||
|  |         for (const point of points) { | ||||||
|  |           boundingBox.from.x = Math.min(boundingBox.from.x, point.x); | ||||||
|  |           boundingBox.from.y = Math.min(boundingBox.from.y, point.y); | ||||||
|  |           boundingBox.to.x = Math.max(boundingBox.to.x, point.x); | ||||||
|  |           boundingBox.to.y = Math.max(boundingBox.to.y, point.y); | ||||||
|  |         } | ||||||
|  |         const newCenter = { | ||||||
|  |           x: -(boundingBox.from.x + boundingBox.to.x) / 2, | ||||||
|  |           y: -(boundingBox.from.y + boundingBox.to.y) / 2, | ||||||
|  |         }; | ||||||
|  |         setPosition(newCenter); | ||||||
|  |         setIsSetup(true); | ||||||
|  |       } | ||||||
|  |     } else if ( | ||||||
|  |       originalRouteData?.center_latitude && | ||||||
|  |       originalRouteData?.center_longitude | ||||||
|  |     ) { | ||||||
|  |       const coordinates = coordinatesToLocal( | ||||||
|  |         originalRouteData?.center_latitude, | ||||||
|  |         originalRouteData?.center_longitude | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       setTransform( | ||||||
|  |         coordinates.x, | ||||||
|  |         coordinates.y, | ||||||
|  |         originalRouteData?.rotate, | ||||||
|  |         originalRouteData?.scale_min | ||||||
|  |       ); | ||||||
|  |       setIsSetup(true); | ||||||
|  |     } | ||||||
|  |   }, [ | ||||||
|  |     points, | ||||||
|  |     originalRouteData?.center_latitude, | ||||||
|  |     originalRouteData?.center_longitude, | ||||||
|  |     originalRouteData?.rotate, | ||||||
|  |     isSetup, | ||||||
|  |     screenCenter, | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|  |   if (!routeData || !stationData || !sightData) { | ||||||
|  |     console.error("routeData, stationData or sightData is null"); | ||||||
|  |     return <div>Loading...</div>; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div style={{ width: "100%", height: "100%" }} ref={parentRef}> | ||||||
|  |       <Application resizeTo={parentRef} background="#fff"> | ||||||
|  |         <InfiniteCanvas> | ||||||
|  |           <TravelPath points={points} /> | ||||||
|  |           {stationData[language].map((obj, index) => ( | ||||||
|  |             <Station | ||||||
|  |               station={obj} | ||||||
|  |               key={obj.id} | ||||||
|  |               ruLabel={ | ||||||
|  |                 language === "ru" | ||||||
|  |                   ? stationData.en[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"); | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         </InfiniteCanvas> | ||||||
|  |       </Application> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										69
									
								
								src/pages/route-preview/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/pages/route-preview/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | 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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface StationPatchData { | ||||||
|  | 	station_id: number; | ||||||
|  | 	offset_x: number; | ||||||
|  | 	offset_y: number; | ||||||
|  | 	transfers: StationTransferData; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SightPatchData { | ||||||
|  | 	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 | ||||||
|  | } | ||||||
							
								
								
									
										14
									
								
								src/pages/route-preview/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/pages/route-preview/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | // approximation | ||||||
|  | export function coordinatesToLocal(latitude: number, longitude: number) { | ||||||
|  |     return { | ||||||
|  |         x: longitude, | ||||||
|  |         y: -latitude*2, | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function localToCoordinates(x: number, y: number) { | ||||||
|  | 	return { | ||||||
|  |         longitude: x, | ||||||
|  |         latitude: -y/2, | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,217 +1,369 @@ | |||||||
| import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' | import { | ||||||
| import {Create, useAutocomplete} from '@refinedev/mui' |   Autocomplete, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   Box, | ||||||
| import {Controller} from 'react-hook-form' |   TextField, | ||||||
|  |   FormControlLabel, | ||||||
|  |   Checkbox, | ||||||
|  |   Typography, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Create, useAutocomplete } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { useState } from "react"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| export const RouteCreate = () => { | export const RouteCreate = () => { | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {formLoading}, |     refineCore: { formLoading }, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     setValue, | ||||||
|  |     formState: { errors }, | ||||||
|   } = useForm({ |   } = useForm({ | ||||||
|     refineCoreProps: { |     refineCoreProps: { | ||||||
|       resource: 'route/', |       resource: "route", | ||||||
|     }, |     }, | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ |   const directions = [ | ||||||
|     resource: 'carrier', |     { | ||||||
|  |       label: "Прямой", | ||||||
|  |       value: true, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: "Обратный", | ||||||
|  |       value: false, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |   const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "carrier", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'short_name', |         field: "short_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: governorAppealAutocompleteProps } = | ||||||
|  |     useAutocomplete({ | ||||||
|  |       resource: "article", | ||||||
|  |  | ||||||
|  |       onSearch: (value) => [ | ||||||
|  |         { | ||||||
|  |           field: "heading", | ||||||
|  |           operator: "contains", | ||||||
|  |           value, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           field: "media_type", | ||||||
|  |           operator: "contains", | ||||||
|  |           value, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |   const [routeDirection, setRouteDirection] = useState(false); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> |     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="carrier_id" |           name="carrier_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...carrierAutocompleteProps} |               {...carrierAutocompleteProps} | ||||||
|               value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 carrierAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.short_name : '' |                 return item ? item.short_name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.short_name | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите перевозчика" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.carrier_id} | ||||||
|  |                   helperText={(errors as any)?.carrier_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('route_number', { |           {...register("route_number", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|             setValueAs: (value) => String(value), |             setValueAs: (value) => String(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.route_number} |           error={!!(errors as any)?.route_number} | ||||||
|           helperText={(errors as any)?.route_number?.message} |           helperText={(errors as any)?.route_number?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Номер маршрута *'} |           label={"Номер маршрута *"} | ||||||
|           name="route_number" |           name="route_number" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <Controller |  | ||||||
|           name="route_direction" // boolean |  | ||||||
|           control={control} |  | ||||||
|           defaultValue={false} |  | ||||||
|           render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут? *" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />} |  | ||||||
|         /> |  | ||||||
|         <Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}> |  | ||||||
|           (Прямой / Обратный) |  | ||||||
|         </Typography> |  | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('path', { |           {...register("path", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|             setValueAs: (value: string) => { |             setValueAs: (value: string) => { | ||||||
|               try { |               try { | ||||||
|                 // Парсим строку в массив массивов |                 // Разбиваем строку на строки и парсим каждую строку как пару координат | ||||||
|                 return JSON.parse(value) |                 const lines = value.trim().split("\n"); | ||||||
|  |                 return lines.map((line) => { | ||||||
|  |                   const [lat, lon] = line | ||||||
|  |                     .trim() | ||||||
|  |                     .split(/[\s,]+/) | ||||||
|  |                     .map(Number); | ||||||
|  |                   if (isNaN(lat) || isNaN(lon)) { | ||||||
|  |                     throw new Error("Invalid coordinates"); | ||||||
|  |                   } | ||||||
|  |                   return [lat, lon]; | ||||||
|  |                 }); | ||||||
|               } catch { |               } catch { | ||||||
|                 return [] |                 return []; | ||||||
|               } |               } | ||||||
|             }, |             }, | ||||||
|             validate: (value: unknown) => { |             validate: (value: unknown) => { | ||||||
|               if (!Array.isArray(value)) return 'Неверный формат' |               if (!Array.isArray(value)) return "Неверный формат"; | ||||||
|               if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { |               if (value.length === 0) | ||||||
|                 return 'Каждая точка должна быть массивом из двух координат' |                 return "Введите хотя бы одну пару координат"; | ||||||
|  |               if ( | ||||||
|  |                 !value.every( | ||||||
|  |                   (point: unknown) => Array.isArray(point) && point.length === 2 | ||||||
|  |                 ) | ||||||
|  |               ) { | ||||||
|  |                 return "Каждая строка должна содержать две координаты"; | ||||||
|               } |               } | ||||||
|               if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { |               if ( | ||||||
|                 return 'Координаты должны быть числами' |                 !value.every((point: unknown[]) => | ||||||
|  |                   point.every( | ||||||
|  |                     (coord: unknown) => | ||||||
|  |                       !isNaN(Number(coord)) && typeof coord === "number" | ||||||
|  |                   ) | ||||||
|  |                 ) | ||||||
|  |               ) { | ||||||
|  |                 return "Координаты должны быть числами"; | ||||||
|               } |               } | ||||||
|               return true |               return true; | ||||||
|             }, |             }, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.path} |           error={!!(errors as any)?.path} | ||||||
|           helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' |           helperText={(errors as any)?.path?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Координаты маршрута *'} |           label={"Координаты маршрута *"} | ||||||
|           name="path" |           name="path" | ||||||
|           placeholder="[[1.1, 2.2], [2.1, 4.5]]" |           placeholder="55.7558 37.6173 | ||||||
|  | 55.7539 37.6208" | ||||||
|  |           multiline | ||||||
|  |           rows={4} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('route_sys_number', { |           {...register("route_sys_number", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.route_sys_number} |           error={!!(errors as any)?.route_sys_number} | ||||||
|           helperText={(errors as any)?.route_sys_number?.message} |           helperText={(errors as any)?.route_sys_number?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Системный номер маршрута *'} |           label={"Номер маршрута в Говорящем Городе *"} | ||||||
|           name="route_sys_number" |           name="route_sys_number" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <Controller | ||||||
|           {...register('governor_appeal', { |           control={control} | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.governor_appeal} |  | ||||||
|           helperText={(errors as any)?.governor_appeal?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Обращение губернатора'} |  | ||||||
|           name="governor_appeal" |           name="governor_appeal" | ||||||
|  |           defaultValue={null} | ||||||
|  |           render={({ field }) => ( | ||||||
|  |             <Autocomplete | ||||||
|  |               {...governorAppealAutocompleteProps} | ||||||
|  |               value={ | ||||||
|  |                 governorAppealAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) ?? null | ||||||
|  |               } | ||||||
|  |               onChange={(_, value) => { | ||||||
|  |                 field.onChange(value?.id ?? ""); | ||||||
|  |               }} | ||||||
|  |               getOptionLabel={(item) => { | ||||||
|  |                 return item ? item.heading : ""; | ||||||
|  |               }} | ||||||
|  |               isOptionEqualToValue={(option, value) => { | ||||||
|  |                 return option.id === value?.id; | ||||||
|  |               }} | ||||||
|  |               filterOptions={(options, { inputValue }) => { | ||||||
|  |                 return options.filter((option) => | ||||||
|  |                   option.heading | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|  |               }} | ||||||
|  |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Обращение губернатора" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.arms} | ||||||
|  |                   helperText={(errors as any)?.arms?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         <input | ||||||
|  |           type="hidden" | ||||||
|  |           {...register("route_direction", { | ||||||
|  |             value: routeDirection, | ||||||
|  |           })} | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         <Autocomplete | ||||||
|  |           options={directions} | ||||||
|  |           defaultValue={directions.find((el) => el.value == false)} | ||||||
|  |           onChange={(_, element) => { | ||||||
|  |             if (element) { | ||||||
|  |               setValue("route_direction", element.value); | ||||||
|  |               setRouteDirection(element.value); | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|  |           renderInput={(params) => ( | ||||||
|  |             <TextField | ||||||
|  |               {...params} | ||||||
|  |               label="Прямой/обратный маршрут" | ||||||
|  |               margin="normal" | ||||||
|  |               variant="outlined" | ||||||
|  |               error={!!errors.arms} | ||||||
|  |               helperText={(errors as any)?.arms?.message} | ||||||
|  |               required | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('scale_min', { |           {...register("scale_min", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => { | ||||||
|  |               if (Number(value) < 0) { | ||||||
|  |                 return 0; | ||||||
|  |               } | ||||||
|  |               return Number(value); | ||||||
|  |             }, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.scale_min} |           error={!!(errors as any)?.scale_min} | ||||||
|           helperText={(errors as any)?.scale_min?.message} |           helperText={(errors as any)?.scale_min?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |           inputProps={{ min: 0 }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Масштаб (мин)'} |           label={"Масштаб (мин)"} | ||||||
|           name="scale_min" |           name="scale_min" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('scale_max', { |           {...register("scale_max", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => { | ||||||
|  |               if (Number(value) < 0) { | ||||||
|  |                 return 0; | ||||||
|  |               } | ||||||
|  |               return Number(value); | ||||||
|  |             }, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.scale_max} |           error={!!(errors as any)?.scale_max} | ||||||
|           helperText={(errors as any)?.scale_max?.message} |           helperText={(errors as any)?.scale_max?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |           inputProps={{ min: 0 }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Масштаб (макс)'} |           label={"Масштаб (макс)"} | ||||||
|           name="scale_max" |           name="scale_max" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('rotate', { |           {...register("rotate", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.rotate} |           error={!!(errors as any)?.rotate} | ||||||
|           helperText={(errors as any)?.rotate?.message} |           helperText={(errors as any)?.rotate?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Поворот'} |           label={"Поворот"} | ||||||
|           name="rotate" |           name="rotate" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('center_latitude', { |           {...register("center_latitude", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.center_latitude} |           error={!!(errors as any)?.center_latitude} | ||||||
|           helperText={(errors as any)?.center_latitude?.message} |           helperText={(errors as any)?.center_latitude?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Центр. широта'} |           label={"Центр. широта"} | ||||||
|           name="center_latitude" |           name="center_latitude" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('center_longitude', { |           {...register("center_longitude", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.center_longitude} |           error={!!(errors as any)?.center_longitude} | ||||||
|           helperText={(errors as any)?.center_longitude?.message} |           helperText={(errors as any)?.center_longitude?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           slotProps={{ inputLabel: { shrink: true } }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Центр. долгота'} |           label={"Центр. долгота"} | ||||||
|           name="center_longitude" |           name="center_longitude" | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|     </Create> |     </Create> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,236 +1,439 @@ | |||||||
| import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' | import { | ||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' |   Autocomplete, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   Box, | ||||||
| import {Controller} from 'react-hook-form' |   TextField, | ||||||
| import {useParams} from 'react-router' |   FormControlLabel, | ||||||
| import {LinkedItems} from '../../components/LinkedItems' |   Checkbox, | ||||||
| import {StationItem, VehicleItem, stationFields, vehicleFields} from './types' |   Typography, | ||||||
|  |   Button, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Edit, useAutocomplete } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller, useWatch } from "react-hook-form"; | ||||||
|  | import { useNavigate, useParams } from "react-router"; | ||||||
|  | import { LinkedItems } from "../../components/LinkedItems"; | ||||||
|  | import { | ||||||
|  |   StationItem, | ||||||
|  |   VehicleItem, | ||||||
|  |   stationFields, | ||||||
|  |   vehicleFields, | ||||||
|  | } from "./types"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { META_LANGUAGE, languageStore } from "@stores"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { LanguageSelector } from "@ui"; | ||||||
|  |  | ||||||
| export const RouteEdit = () => { | export const RouteEdit = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|   } = useForm({}) |     refineCore: { queryResult }, | ||||||
|  |     setValue, | ||||||
|  |     getValues, | ||||||
|  |     watch, | ||||||
|  |   } = useForm({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |   const routeDirectionFromServer = watch("route_direction"); | ||||||
|  |  | ||||||
|   const {id: routeId} = useParams<{id: string}>() |   const [routeDirection, setRouteDirection] = useState(false); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const directions = [ | ||||||
|  |     { | ||||||
|  |       label: "Прямой", | ||||||
|  |       value: true, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: "Обратный", | ||||||
|  |       value: false, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|   const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ |   const { id: routeId } = useParams<{ id: string }>(); | ||||||
|     resource: 'carrier', |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (queryResult?.data?.data && Array.isArray(queryResult.data.data.path)) { | ||||||
|  |       const formattedPath = queryResult.data.data.path | ||||||
|  |         .map((coords) => coords.join(" ")) | ||||||
|  |         .join("\n"); | ||||||
|  |  | ||||||
|  |       setValue("path", formattedPath); | ||||||
|  |     } | ||||||
|  |   }, [queryResult?.data?.data, setValue]); | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "carrier", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'short_name', |         field: "short_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |     ...META_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const carrierId = useWatch({ control, name: "carrier_id" }); | ||||||
|  |   const cityId = carrierAutocompleteProps.options.find( | ||||||
|  |     (option) => option.id === carrierId | ||||||
|  |   )?.city_id; | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: governorAppealAutocompleteProps } = | ||||||
|  |     useAutocomplete({ | ||||||
|  |       resource: "article", | ||||||
|  |  | ||||||
|  |       onSearch: (value) => [ | ||||||
|  |         { | ||||||
|  |           field: "heading", | ||||||
|  |           operator: "contains", | ||||||
|  |           value, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           field: "media_type", | ||||||
|  |           operator: "contains", | ||||||
|  |           value, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       ...META_LANGUAGE(language), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (routeDirectionFromServer) { | ||||||
|  |       setRouteDirection(routeDirectionFromServer); | ||||||
|  |     } | ||||||
|  |   }, [routeDirectionFromServer]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | ||||||
|         <Controller |         <Box | ||||||
|           control={control} |           component="form" | ||||||
|           name="carrier_id" |           sx={{ display: "flex", flexDirection: "column" }} | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           autoComplete="off" | ||||||
|           defaultValue={null} |         > | ||||||
|           render={({field}) => ( |           <LanguageSelector /> | ||||||
|             <Autocomplete |           <Controller | ||||||
|               {...carrierAutocompleteProps} |             control={control} | ||||||
|               value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} |             name="carrier_id" | ||||||
|               onChange={(_, value) => { |             rules={{ required: "Это поле является обязательным" }} | ||||||
|                 field.onChange(value?.id || '') |             defaultValue={null} | ||||||
|               }} |             render={({ field }) => ( | ||||||
|               getOptionLabel={(item) => { |               <Autocomplete | ||||||
|                 return item ? item.short_name : '' |                 {...carrierAutocompleteProps} | ||||||
|               }} |                 value={ | ||||||
|               isOptionEqualToValue={(option, value) => { |                   carrierAutocompleteProps.options.find( | ||||||
|                 return option.id === value?.id |                     (option) => option.id === field.value | ||||||
|               }} |                   ) || null | ||||||
|               filterOptions={(options, {inputValue}) => { |  | ||||||
|                 return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) |  | ||||||
|               }} |  | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />} |  | ||||||
|             /> |  | ||||||
|           )} |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('route_number', { |  | ||||||
|             required: 'Это поле является обязательным', |  | ||||||
|             setValueAs: (value) => String(value), |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.route_number} |  | ||||||
|           helperText={(errors as any)?.route_number?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="text" |  | ||||||
|           label={'Номер маршрута'} |  | ||||||
|           name="route_number" |  | ||||||
|         /> |  | ||||||
|         <Controller |  | ||||||
|           name="route_direction" // boolean |  | ||||||
|           control={control} |  | ||||||
|           defaultValue={false} |  | ||||||
|           render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут?" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />} |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}> |  | ||||||
|           (Прямой / Обратный) |  | ||||||
|         </Typography> |  | ||||||
|  |  | ||||||
|         <Controller |  | ||||||
|           name="path" |  | ||||||
|           control={control} |  | ||||||
|           defaultValue={[]} |  | ||||||
|           rules={{ |  | ||||||
|             required: 'Это поле является обязательным', |  | ||||||
|             validate: (value: unknown) => { |  | ||||||
|               if (!Array.isArray(value)) return 'Неверный формат' |  | ||||||
|               if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { |  | ||||||
|                 return 'Каждая точка должна быть массивом из двух координат' |  | ||||||
|               } |  | ||||||
|               if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { |  | ||||||
|                 return 'Координаты должны быть числами' |  | ||||||
|               } |  | ||||||
|               return true |  | ||||||
|             }, |  | ||||||
|           }} |  | ||||||
|           render={({field, fieldState: {error}}) => ( |  | ||||||
|             <TextField |  | ||||||
|               {...field} |  | ||||||
|               value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''} |  | ||||||
|               onChange={(e) => { |  | ||||||
|                 try { |  | ||||||
|                   const parsed = JSON.parse(e.target.value) |  | ||||||
|                   field.onChange(parsed) |  | ||||||
|                 } catch { |  | ||||||
|                   field.onChange([]) |  | ||||||
|                 } |                 } | ||||||
|               }} |                 onChange={(_, value) => { | ||||||
|               error={!!error} |                   field.onChange(value?.id || ""); | ||||||
|               helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' |                 }} | ||||||
|               margin="normal" |                 getOptionLabel={(item) => { | ||||||
|               fullWidth |                   return item ? item.short_name : ""; | ||||||
|               InputLabelProps={{shrink: true}} |                 }} | ||||||
|               type="text" |                 isOptionEqualToValue={(option, value) => { | ||||||
|               label={'Координаты маршрута'} |                   return option.id === value?.id; | ||||||
|               placeholder="[[1.1, 2.2], [2.1, 4.5]]" |                 }} | ||||||
|               sx={{ |                 filterOptions={(options, { inputValue }) => { | ||||||
|                 marginBottom: 2, |                   return options.filter((option) => | ||||||
|               }} |                     option.short_name | ||||||
|  |                       .toLowerCase() | ||||||
|  |                       .includes(inputValue.toLowerCase()) | ||||||
|  |                   ); | ||||||
|  |                 }} | ||||||
|  |                 renderInput={(params) => ( | ||||||
|  |                   <TextField | ||||||
|  |                     {...params} | ||||||
|  |                     label="Выберите перевозчика" | ||||||
|  |                     margin="normal" | ||||||
|  |                     variant="outlined" | ||||||
|  |                     error={!!errors.carrier_id} | ||||||
|  |                     helperText={(errors as any)?.carrier_id?.message} | ||||||
|  |                     required | ||||||
|  |                   /> | ||||||
|  |                 )} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("route_number", { | ||||||
|  |               required: "Это поле является обязательным", | ||||||
|  |               setValueAs: (value) => String(value), | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.route_number} | ||||||
|  |             helperText={(errors as any)?.route_number?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="text" | ||||||
|  |             label={"Номер маршрута"} | ||||||
|  |             name="route_number" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <input type="hidden" {...register("route_direction")} /> | ||||||
|  |  | ||||||
|  |           <Autocomplete | ||||||
|  |             options={directions} | ||||||
|  |             value={directions.find((el) => el.value == routeDirection)} | ||||||
|  |             onChange={(_, element) => { | ||||||
|  |               if (element) { | ||||||
|  |                 setValue("route_direction", element.value); | ||||||
|  |                 setRouteDirection(element.value); | ||||||
|  |               } | ||||||
|  |             }} | ||||||
|  |             renderInput={(params) => ( | ||||||
|  |               <TextField | ||||||
|  |                 {...params} | ||||||
|  |                 label="Прямой/обратный маршрут" | ||||||
|  |                 margin="normal" | ||||||
|  |                 variant="outlined" | ||||||
|  |                 error={!!errors.arms} | ||||||
|  |                 helperText={(errors as any)?.arms?.message} | ||||||
|  |                 required | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("path", { | ||||||
|  |               required: "Это поле является обязательным", | ||||||
|  |               setValueAs: (value: string) => { | ||||||
|  |                 try { | ||||||
|  |                   const lines = value.trim().split("\n"); | ||||||
|  |                   return lines.map((line) => { | ||||||
|  |                     const [lat, lon] = line | ||||||
|  |                       .trim() | ||||||
|  |                       .split(/[\s,]+/) | ||||||
|  |                       .map(Number); | ||||||
|  |                     return [lat, lon]; | ||||||
|  |                   }); | ||||||
|  |                 } catch { | ||||||
|  |                   return []; | ||||||
|  |                 } | ||||||
|  |               }, | ||||||
|  |               validate: (value: unknown) => { | ||||||
|  |                 if (!Array.isArray(value)) return "Неверный формат"; | ||||||
|  |                 if (value.length === 0) | ||||||
|  |                   return "Введите хотя бы одну пару координат"; | ||||||
|  |                 if ( | ||||||
|  |                   !value.every( | ||||||
|  |                     (point: unknown) => | ||||||
|  |                       Array.isArray(point) && point.length === 2 | ||||||
|  |                   ) | ||||||
|  |                 ) { | ||||||
|  |                   return "Каждая строка должна содержать две координаты"; | ||||||
|  |                 } | ||||||
|  |                 if ( | ||||||
|  |                   !value.every((point: unknown[]) => | ||||||
|  |                     point.every( | ||||||
|  |                       (coord: unknown) => | ||||||
|  |                         !isNaN(Number(coord)) && typeof coord === "number" | ||||||
|  |                     ) | ||||||
|  |                   ) | ||||||
|  |                 ) { | ||||||
|  |                   return "Координаты должны быть числами"; | ||||||
|  |                 } | ||||||
|  |                 return true; | ||||||
|  |               }, | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.path} | ||||||
|  |             helperText={(errors as any)?.path?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="text" | ||||||
|  |             label={"Координаты маршрута *"} | ||||||
|  |             name="path" | ||||||
|  |             placeholder="55.7558 37.6173 | ||||||
|  | 55.7539 37.6208" | ||||||
|  |             multiline | ||||||
|  |             rows={4} | ||||||
|  |             sx={{ | ||||||
|  |               marginBottom: 2, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("route_sys_number", { | ||||||
|  |               required: "Это поле является обязательным", | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.route_sys_number} | ||||||
|  |             helperText={(errors as any)?.route_sys_number?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Номер маршрута в Говорящем Городе *"} | ||||||
|  |             name="route_sys_number" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <Controller | ||||||
|  |             control={control} | ||||||
|  |             name="governor_appeal" | ||||||
|  |             defaultValue={null} | ||||||
|  |             render={({ field }) => ( | ||||||
|  |               <Autocomplete | ||||||
|  |                 {...governorAppealAutocompleteProps} | ||||||
|  |                 value={ | ||||||
|  |                   governorAppealAutocompleteProps.options.find( | ||||||
|  |                     (option) => option.id === field.value | ||||||
|  |                   ) ?? null | ||||||
|  |                 } | ||||||
|  |                 onChange={(_, value) => { | ||||||
|  |                   field.onChange(value?.id ?? ""); | ||||||
|  |                 }} | ||||||
|  |                 getOptionLabel={(item) => { | ||||||
|  |                   return item ? item.heading : ""; | ||||||
|  |                 }} | ||||||
|  |                 isOptionEqualToValue={(option, value) => { | ||||||
|  |                   return option.id === value?.id; | ||||||
|  |                 }} | ||||||
|  |                 filterOptions={(options, { inputValue }) => { | ||||||
|  |                   return options.filter((option) => | ||||||
|  |                     option.heading | ||||||
|  |                       .toLowerCase() | ||||||
|  |                       .includes(inputValue.toLowerCase()) | ||||||
|  |                   ); | ||||||
|  |                 }} | ||||||
|  |                 renderInput={(params) => ( | ||||||
|  |                   <TextField | ||||||
|  |                     {...params} | ||||||
|  |                     label="Обращение губернатора" | ||||||
|  |                     margin="normal" | ||||||
|  |                     variant="outlined" | ||||||
|  |                     error={!!errors.arms} | ||||||
|  |                     helperText={(errors as any)?.arms?.message} | ||||||
|  |                     required | ||||||
|  |                   /> | ||||||
|  |                 )} | ||||||
|  |               /> | ||||||
|  |             )} | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("scale_min", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |               setValueAs: (value) => { | ||||||
|  |                 if (Number(value) < 0) { | ||||||
|  |                   return 0; | ||||||
|  |                 } | ||||||
|  |                 return Number(value); | ||||||
|  |               }, | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.scale_min} | ||||||
|  |             helperText={(errors as any)?.scale_min?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Масштаб (мин)"} | ||||||
|  |             name="scale_min" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("scale_max", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |               setValueAs: (value) => { | ||||||
|  |                 if (Number(value) < 0) { | ||||||
|  |                   return 0; | ||||||
|  |                 } | ||||||
|  |                 return Number(value); | ||||||
|  |               }, | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.scale_max} | ||||||
|  |             helperText={(errors as any)?.scale_max?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Масштаб (макс)"} | ||||||
|  |             name="scale_max" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("rotate", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |               setValueAs: (value) => Number(value), | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.rotate} | ||||||
|  |             helperText={(errors as any)?.rotate?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Поворот"} | ||||||
|  |             name="rotate" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("center_latitude", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |               setValueAs: (value) => Number(value), | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.center_latitude} | ||||||
|  |             helperText={(errors as any)?.center_latitude?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Центр. широта"} | ||||||
|  |             name="center_latitude" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|  |           <TextField | ||||||
|  |             {...register("center_longitude", { | ||||||
|  |               // required: 'Это поле является обязательным', | ||||||
|  |               setValueAs: (value) => Number(value), | ||||||
|  |             })} | ||||||
|  |             error={!!(errors as any)?.center_longitude} | ||||||
|  |             helperText={(errors as any)?.center_longitude?.message} | ||||||
|  |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             slotProps={{ inputLabel: { shrink: true } }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Центр. долгота"} | ||||||
|  |             name="center_longitude" | ||||||
|  |           /> | ||||||
|  |         </Box> | ||||||
|  |  | ||||||
|  |         {routeId && ( | ||||||
|  |           <> | ||||||
|  |             <LinkedItems<StationItem> | ||||||
|  |               type="edit" | ||||||
|  |               parentId={routeId} | ||||||
|  |               parentResource="route" | ||||||
|  |               childResource="station" | ||||||
|  |               fields={stationFields} | ||||||
|  |               title="остановки" | ||||||
|  |               dragAllowed={true} | ||||||
|  |               cityId={cityId} | ||||||
|             /> |             /> | ||||||
|           )} |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |             {/* <LinkedItems<VehicleItem> | ||||||
|           {...register('route_sys_number', { |               type="edit" | ||||||
|             required: 'Это поле является обязательным', |               parentId={routeId} | ||||||
|           })} |               parentResource="route" | ||||||
|           error={!!(errors as any)?.route_sys_number} |               childResource="vehicle" | ||||||
|           helperText={(errors as any)?.route_sys_number?.message} |               fields={vehicleFields} | ||||||
|           margin="normal" |               title="транспортные средства" | ||||||
|           fullWidth |             /> */} | ||||||
|           InputLabelProps={{shrink: true}} |           </> | ||||||
|           type="number" |         )} | ||||||
|           label={'Системный номер маршрута *'} |  | ||||||
|           name="route_sys_number" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |         <Box sx={{ display: "flex", justifyContent: "flex-start" }}> | ||||||
|           {...register('governor_appeal', { |           <Button | ||||||
|             // required: 'Это поле является обязательным', |             variant="contained" | ||||||
|           })} |             color="primary" | ||||||
|           error={!!(errors as any)?.governor_appeal} |             onClick={() => navigate(`/route-preview/${routeId}`)} | ||||||
|           helperText={(errors as any)?.governor_appeal?.message} |           > | ||||||
|           margin="normal" |             Предпросмотр маршрута | ||||||
|           fullWidth |           </Button> | ||||||
|           InputLabelProps={{shrink: true}} |         </Box> | ||||||
|           type="number" |  | ||||||
|           label={'Обращение губернатора'} |  | ||||||
|           name="governor_appeal" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('scale_min', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.scale_min} |  | ||||||
|           helperText={(errors as any)?.scale_min?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Масштаб (мин)'} |  | ||||||
|           name="scale_min" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('scale_max', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.scale_max} |  | ||||||
|           helperText={(errors as any)?.scale_max?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Масштаб (макс)'} |  | ||||||
|           name="scale_max" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('rotate', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.rotate} |  | ||||||
|           helperText={(errors as any)?.rotate?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Поворот'} |  | ||||||
|           name="rotate" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('center_latitude', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.center_latitude} |  | ||||||
|           helperText={(errors as any)?.center_latitude?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Центр. широта'} |  | ||||||
|           name="center_latitude" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('center_longitude', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.center_longitude} |  | ||||||
|           helperText={(errors as any)?.center_longitude?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Центр. долгота'} |  | ||||||
|           name="center_longitude" |  | ||||||
|         /> |  | ||||||
|       </Box> |       </Box> | ||||||
|  |  | ||||||
|       {routeId && ( |  | ||||||
|         <> |  | ||||||
|           <LinkedItems<StationItem> type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" /> |  | ||||||
|  |  | ||||||
|           <LinkedItems<VehicleItem> type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" /> |  | ||||||
|         </> |  | ||||||
|       )} |  | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,156 +1,190 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import {Typography} from '@mui/material' |   DeleteButton, | ||||||
| import React from 'react' |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import { Button, Typography } from "@mui/material"; | ||||||
|  | import React from "react"; | ||||||
|  | import MapIcon from "@mui/icons-material/Map"; | ||||||
|  |  | ||||||
| import {localeText} from '../../locales/ru/localeText' | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { useLink } from "@refinedev/core"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  |  | ||||||
| export const RouteList = () => { | export const RouteList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({ |   const Link = useLink(); | ||||||
|     resource: 'route/', |   const { language } = languageStore; | ||||||
|   }) |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "route/", | ||||||
|  |     meta: META_LANGUAGE(language), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 70, |         minWidth: 70, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'carrier_id', |         field: "carrier_id", | ||||||
|         headerName: 'ID перевозчика', |         headerName: "ID перевозчика", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'carrier', |         field: "carrier", | ||||||
|         headerName: 'Перевозчик', |         headerName: "Перевозчик", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'route_number', |         field: "route_number", | ||||||
|         headerName: 'Номер маршрута', |         headerName: "Номер маршрута", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'route_sys_number', |  | ||||||
|         headerName: 'Системный номер маршрута', |  | ||||||
|         type: 'string', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'governor_appeal', |  | ||||||
|         headerName: 'Обращение губернатора', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'scale_min', |  | ||||||
|         headerName: 'Масштаб (мин)', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'scale_max', |  | ||||||
|         headerName: 'Масштаб (макс)', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'rotate', |  | ||||||
|         headerName: 'Поворот', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'center_latitude', |  | ||||||
|         headerName: 'Центр. широта', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'center_longitude', |  | ||||||
|         headerName: 'Центр. долгота', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'route_direction', |  | ||||||
|         headerName: 'Направление маршрута', |  | ||||||
|         type: 'boolean', |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|         minWidth: 120, |  | ||||||
|         flex: 1, |         flex: 1, | ||||||
|         renderCell: ({value}) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>, |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "route_sys_number", | ||||||
|         headerName: 'Действия', |         headerName: "Номер маршрута в Говорящем Городе", | ||||||
|         cellClassName: 'route-actions', |         type: "string", | ||||||
|         align: 'right', |  | ||||||
|         headerAlign: 'center', |  | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|  |  | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "governor_appeal", | ||||||
|  |         headerName: "Обращение губернатора", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "scale_min", | ||||||
|  |         headerName: "Масштаб (мин)", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "scale_max", | ||||||
|  |         headerName: "Масштаб (макс)", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "rotate", | ||||||
|  |         headerName: "Поворот", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "center_latitude", | ||||||
|  |         headerName: "Центр. широта", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "center_longitude", | ||||||
|  |         headerName: "Центр. долгота", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "route_direction", | ||||||
|  |         headerName: "Направление маршрута", | ||||||
|  |         type: "boolean", | ||||||
|  |         display: "flex", | ||||||
|  |         flex: 1, | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |         minWidth: 120, | ||||||
|  |         renderCell: ({ value }) => ( | ||||||
|  |           <Typography style={{ color: value ? "#48989f" : "#7f6b58" }}> | ||||||
|  |             {value ? "прямое" : "обратное"} | ||||||
|  |           </Typography> | ||||||
|  |         ), | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "actions", | ||||||
|  |         headerName: "Действия", | ||||||
|  |         cellClassName: "route-actions", | ||||||
|  |         align: "right", | ||||||
|  |         headerAlign: "center", | ||||||
|  |         minWidth: 160, | ||||||
|  |         display: "flex", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|  |               <Link to={`/route-preview/${row.id}`}> | ||||||
|  |                 <Button sx={{ minWidth: 0 }}> | ||||||
|  |                   <MapIcon fontSize="small" /> | ||||||
|  |                 </Button> | ||||||
|  |               </Link> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} /> |       <CustomDataGrid | ||||||
|  |         {...dataGridProps} | ||||||
|  |         columns={columns} | ||||||
|  |         localeText={localeText} | ||||||
|  |         getRowId={(row: any) => row.id} | ||||||
|  |         languageEnabled | ||||||
|  |       /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,67 +1,118 @@ | |||||||
| import {Stack, Typography, Box} from '@mui/material' | import { Stack, Typography, Box, Button } from "@mui/material"; | ||||||
| import {useShow} from '@refinedev/core' | import { useShow } from "@refinedev/core"; | ||||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||||
| import {LinkedItems} from '../../components/LinkedItems' | import { LinkedItems } from "../../components/LinkedItems"; | ||||||
| import {StationItem, VehicleItem, stationFields, vehicleFields} from './types' | import { | ||||||
|  |   StationItem, | ||||||
|  |   VehicleItem, | ||||||
|  |   SightItem, | ||||||
|  |   sightFields, | ||||||
|  |   stationFields, | ||||||
|  |   vehicleFields, | ||||||
|  | } from "./types"; | ||||||
|  | import { useNavigate, useParams } from "react-router"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
| export const RouteShow = () => { | export const RouteShow = observer(() => { | ||||||
|   const {query} = useShow({}) |   const { query } = useShow({}); | ||||||
|   const {data, isLoading} = query |   const { data, isLoading } = query; | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|  |   const { id } = useParams(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |  | ||||||
|   const fields = [ |   const fields = [ | ||||||
|     {label: 'Перевозчик', data: 'carrier'}, |     { label: "Перевозчик", data: "carrier" }, | ||||||
|     {label: 'Номер маршрута', data: 'route_number'}, |     { label: "Номер маршрута", data: "route_number" }, | ||||||
|     { |     { | ||||||
|       label: 'Направление маршрута', |       label: "Направление маршрута", | ||||||
|       data: 'route_direction', |       data: "route_direction", | ||||||
|       render: (value: number[][]) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>, |       render: (value: number[][]) => ( | ||||||
|  |         <Typography style={{ color: value ? "#48989f" : "#7f6b58" }}> | ||||||
|  |           {value ? "прямое" : "обратное"} | ||||||
|  |         </Typography> | ||||||
|  |       ), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       label: 'Координаты маршрута', |       label: "Координаты маршрута", | ||||||
|       data: 'path', |       data: "path", | ||||||
|       render: (value: number[][]) => ( |       render: (value: number[][]) => ( | ||||||
|         <Box |         <Box | ||||||
|           sx={{ |           sx={{ | ||||||
|             fontFamily: 'monospace', |             fontFamily: "monospace", | ||||||
|             bgcolor: (theme) => theme.palette.background.paper, |             bgcolor: (theme) => theme.palette.background.paper, | ||||||
|             p: 2, |             p: 2, | ||||||
|             borderRadius: 1, |             borderRadius: 1, | ||||||
|             maxHeight: '200px', |             maxHeight: "200px", | ||||||
|             overflow: 'auto', |             overflow: "auto", | ||||||
|           }} |           }} | ||||||
|         > |         > | ||||||
|           {JSON.stringify(value)} |           {value?.map((point, index) => ( | ||||||
|           {/* {value?.map((point, index) => ( |             <Typography key={index} sx={{ mb: 0.5 }}> | ||||||
|             <Typography key={index} sx={{mb: 0.5}}> |               {point[0]}, {point[1]} | ||||||
|               Точка {index + 1}: [{point[0]}, {point[1]}] |  | ||||||
|             </Typography> |             </Typography> | ||||||
|           ))} */} |           ))} | ||||||
|         </Box> |         </Box> | ||||||
|       ), |       ), | ||||||
|     }, |     }, | ||||||
|   ] |   ]; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show isLoading={isLoading}> |     <Show isLoading={isLoading}> | ||||||
|       <Stack gap={4}> |       <Stack gap={4}> | ||||||
|         {fields.map(({label, data, render}) => ( |         {fields.map(({ label, data, render }) => ( | ||||||
|           <Stack key={data} gap={1}> |           <Stack key={data} gap={1}> | ||||||
|             <Typography variant="body1" fontWeight="bold"> |             <Typography variant="body1" fontWeight="bold"> | ||||||
|               {label} |               {label} | ||||||
|             </Typography> |             </Typography> | ||||||
|             {render ? render(record?.[data]) : <TextField value={record?.[data]} />} |             {render ? ( | ||||||
|  |               render(record?.[data]) | ||||||
|  |             ) : ( | ||||||
|  |               <TextField value={record?.[data]} /> | ||||||
|  |             )} | ||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|  |  | ||||||
|         {record?.id && ( |         {record?.id && ( | ||||||
|           <> |           <> | ||||||
|             <LinkedItems<StationItem> type="show" parentId={record.id} parentResource="route" childResource="station" fields={stationFields} title="станции" /> |             <LinkedItems<StationItem> | ||||||
|  |               type="show" | ||||||
|  |               parentId={record.id} | ||||||
|  |               parentResource="route" | ||||||
|  |               childResource="station" | ||||||
|  |               fields={stationFields} | ||||||
|  |               title="остановки" | ||||||
|  |             /> | ||||||
|  |  | ||||||
|             <LinkedItems<VehicleItem> type="show" parentId={record.id} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" /> |             {/* <LinkedItems<VehicleItem> | ||||||
|  |               type="show" | ||||||
|  |               parentId={record.id} | ||||||
|  |               parentResource="route" | ||||||
|  |               childResource="vehicle" | ||||||
|  |               fields={vehicleFields} | ||||||
|  |               title="транспортные средства" | ||||||
|  |             /> */} | ||||||
|  |  | ||||||
|  |             <LinkedItems<SightItem> | ||||||
|  |               type="show" | ||||||
|  |               parentId={record.id} | ||||||
|  |               parentResource="route" | ||||||
|  |               childResource="sight" | ||||||
|  |               fields={sightFields} | ||||||
|  |               title="достопримечательности" | ||||||
|  |             /> | ||||||
|           </> |           </> | ||||||
|         )} |         )} | ||||||
|  |  | ||||||
|  |         <Box sx={{ display: "flex", justifyContent: "flex-start" }}> | ||||||
|  |           <Button | ||||||
|  |             variant="contained" | ||||||
|  |             color="primary" | ||||||
|  |             onClick={() => navigate(`/route-preview/${id}`)} | ||||||
|  |           > | ||||||
|  |             Предпросмотр маршрута | ||||||
|  |           </Button> | ||||||
|  |         </Box> | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,35 +1,53 @@ | |||||||
| import {VEHICLE_TYPES} from '../../lib/constants' | import { VEHICLE_TYPES } from "../../lib/constants"; | ||||||
|  |  | ||||||
| export type StationItem = { | export type StationItem = { | ||||||
|   id: number |   id: number; | ||||||
|   name: string |   name: string; | ||||||
|   description: string |   description: string; | ||||||
|   [key: string]: string | number |   offset_x: number; | ||||||
| } |   offset_y: number; | ||||||
|  |   [key: string]: string | number; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export type VehicleItem = { | export type VehicleItem = { | ||||||
|   id: number |   id: number; | ||||||
|   tail_number: number |   tail_number: number; | ||||||
|   type: number |   type: number; | ||||||
|   [key: string]: string | number |   [key: string]: string | number; | ||||||
| } | }; | ||||||
|  |  | ||||||
|  | export type SightItem = { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   city: string; | ||||||
|  |   city_id: number; | ||||||
|  |   address: string; | ||||||
|  |   [key: string]: string | number; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export type FieldType<T> = { | export type FieldType<T> = { | ||||||
|   label: string |   label: string; | ||||||
|   data: keyof T |   data: keyof T; | ||||||
|   render?: (value: any) => React.ReactNode |   render?: (value: any) => React.ReactNode; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const stationFields: Array<FieldType<StationItem>> = [ | export const stationFields: Array<FieldType<StationItem>> = [ | ||||||
|   {label: 'Название', data: 'system_name'}, |   { label: "Название", data: "name" }, | ||||||
|   {label: 'Описание', data: 'description'}, |   { label: "Описание", data: "description" }, | ||||||
| ] | ]; | ||||||
|  |  | ||||||
|  | export const sightFields: Array<FieldType<SightItem>> = [ | ||||||
|  |   { label: "Название", data: "name" }, | ||||||
|  |   { label: "Город", data: "city" }, | ||||||
|  |   { label: "Адрес", data: "address" }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
| export const vehicleFields: Array<FieldType<VehicleItem>> = [ | export const vehicleFields: Array<FieldType<VehicleItem>> = [ | ||||||
|   {label: 'Бортовой номер', data: 'tail_number'}, |   { label: "Бортовой номер", data: "tail_number" }, | ||||||
|   { |   { | ||||||
|     label: 'Тип', |     label: "Тип", | ||||||
|     data: 'type', |     data: "type", | ||||||
|     render: (value: number) => VEHICLE_TYPES.find((type) => type.value === value)?.label || value, |     render: (value: number) => | ||||||
|  |       VEHICLE_TYPES.find((type) => type.value === value)?.label || value, | ||||||
|   }, |   }, | ||||||
| ] | ]; | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,128 +1,169 @@ | |||||||
| import React from 'react' | import React from "react"; | ||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import {Stack} from '@mui/material' |   DeleteButton, | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' |   EditButton, | ||||||
| import {localeText} from '../../locales/ru/localeText' |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import { Stack } from "@mui/material"; | ||||||
|  | import { CustomDataGrid } from "@components"; | ||||||
|  | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { cityStore, languageStore } from "@stores"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  |  | ||||||
| export const SightList = () => { | export const SightList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({resource: 'sight/'}) |   const { city_id } = cityStore; | ||||||
|  |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "sight", | ||||||
|  |  | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     filters: { | ||||||
|  |       permanent: [ | ||||||
|  |         { | ||||||
|  |           field: "cityID", | ||||||
|  |           operator: "eq", | ||||||
|  |           value: city_id === "0" ? null : city_id, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 70, |         minWidth: 70, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         headerName: 'Название', |         headerName: "Название", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'latitude', |  | ||||||
|         headerName: 'Широта', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 150, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'longitude', |  | ||||||
|         headerName: 'Долгота', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 150, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'city_id', |  | ||||||
|         headerName: 'ID города', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 70, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'city', |  | ||||||
|         headerName: 'Город', |  | ||||||
|         type: 'string', |  | ||||||
|         minWidth: 100, |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'thumbnail', |         field: "latitude", | ||||||
|         headerName: 'Карточка', |         headerName: "Широта", | ||||||
|         type: 'string', |         type: "number", | ||||||
|  |         minWidth: 150, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "longitude", | ||||||
|  |         headerName: "Долгота", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 150, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "city_id", | ||||||
|  |         headerName: "ID города", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 70, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "city", | ||||||
|  |         headerName: "Город", | ||||||
|  |         type: "string", | ||||||
|  |         minWidth: 100, | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |         flex: 1, | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "thumbnail", | ||||||
|  |         headerName: "Карточка", | ||||||
|  |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'watermark_lu', |         field: "watermark_lu", | ||||||
|         headerName: 'Вод. знак (lu)', |         headerName: "Вод. знак (lu)", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'watermark_rd', |         field: "watermark_rd", | ||||||
|         headerName: 'Вод. знак (rd)', |         headerName: "Вод. знак (rd)", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'left_article', |         field: "left_article", | ||||||
|         headerName: 'Левая статья', |         headerName: "Левая статья", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'preview_article', |         field: "preview_article", | ||||||
|         headerName: 'Пред. просмотр статьи', |         headerName: "Пред. просмотр статьи", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "actions", | ||||||
|         headerName: 'Действия', |         headerName: "Действия", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <Stack gap={2.5}> |       <Stack gap={2.5}> | ||||||
|         <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates /> |         <CustomDataGrid | ||||||
|  |           {...dataGridProps} | ||||||
|  |           languageEnabled | ||||||
|  |           columns={columns} | ||||||
|  |           localeText={localeText} | ||||||
|  |           getRowId={(row: any) => row.id} | ||||||
|  |           hasCoordinates | ||||||
|  |         /> | ||||||
|       </Stack> |       </Stack> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,27 +1,28 @@ | |||||||
| import {Stack, Typography} from '@mui/material' | import { Stack, Typography } from "@mui/material"; | ||||||
| import {useShow} from '@refinedev/core' | import { useShow } from "@refinedev/core"; | ||||||
| import {Show, TextFieldComponent} from '@refinedev/mui' | import { Show, TextFieldComponent } from "@refinedev/mui"; | ||||||
| import {LinkedItems} from '../../components/LinkedItems' | import { LinkedItems } from "../../components/LinkedItems"; | ||||||
| import {ArticleItem, articleFields} from './types' | import { ArticleItem, articleFields } from "./types"; | ||||||
|  |  | ||||||
| export const SightShow = () => { | export const SightShow = () => { | ||||||
|   const {query} = useShow({}) |   const { query } = useShow({}); | ||||||
|   const {data, isLoading} = query |   const { data, isLoading } = query; | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|  |  | ||||||
|   const fields = [ |   const fields = [ | ||||||
|     // {label: 'ID', data: 'id'}, |     // {label: 'ID', data: 'id'}, | ||||||
|     {label: 'Название', data: 'name'}, |     { label: "Название", data: "name" }, | ||||||
|     // {label: 'Широта', data: 'latitude'}, #* |     // {label: 'Широта', data: 'latitude'}, #* | ||||||
|     // {label: 'Долгота', data: 'longitude'}, #* |     // {label: 'Долгота', data: 'longitude'}, #* | ||||||
|     // {label: 'ID города', data: 'city_id'}, |     // {label: 'ID города', data: 'city_id'}, | ||||||
|     {label: 'Город', data: 'city'}, |     { label: "Адрес", data: "address" }, | ||||||
|   ] |     { label: "Город", data: "city" }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show isLoading={isLoading}> |     <Show isLoading={isLoading}> | ||||||
|       <Stack gap={4}> |       <Stack gap={4}> | ||||||
|         {fields.map(({label, data}) => ( |         {fields.map(({ label, data }) => ( | ||||||
|           <Stack key={data} gap={1}> |           <Stack key={data} gap={1}> | ||||||
|             <Typography variant="body1" fontWeight="bold"> |             <Typography variant="body1" fontWeight="bold"> | ||||||
|               {label} |               {label} | ||||||
| @@ -30,8 +31,17 @@ export const SightShow = () => { | |||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|  |  | ||||||
|         {record?.id && <LinkedItems<ArticleItem> type="show" parentId={record.id} parentResource="sight" childResource="article" fields={articleFields} title="статьи" />} |         {record?.id && ( | ||||||
|  |           <LinkedItems<ArticleItem> | ||||||
|  |             type="show" | ||||||
|  |             parentId={record.id} | ||||||
|  |             parentResource="sight" | ||||||
|  |             childResource="article" | ||||||
|  |             fields={articleFields} | ||||||
|  |             title="статьи" | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
							
								
								
									
										61
									
								
								src/pages/snapshot/create.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/pages/snapshot/create.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | import { Box, TextField, Typography, Paper } from "@mui/material"; | ||||||
|  | import { Create } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller, FieldValues } from "react-hook-form"; | ||||||
|  | import React, { useState, useEffect } from "react"; | ||||||
|  | import ReactMarkdown from "react-markdown"; | ||||||
|  | import { MarkdownEditor } from "../../components/MarkdownEditor"; | ||||||
|  | import "easymde/dist/easymde.min.css"; | ||||||
|  | import { LanguageSelector } from "@ui"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { | ||||||
|  |   EVERY_LANGUAGE, | ||||||
|  |   Languages, | ||||||
|  |   languageStore, | ||||||
|  |   META_LANGUAGE, | ||||||
|  | } from "@stores"; | ||||||
|  | import rehypeRaw from "rehype-raw"; | ||||||
|  |  | ||||||
|  | const MemoizedSimpleMDE = React.memo(MarkdownEditor); | ||||||
|  |  | ||||||
|  | export const SnapshotCreate = observer(() => { | ||||||
|  |   const { | ||||||
|  |     saveButtonProps, | ||||||
|  |     refineCore: { formLoading, onFinish }, | ||||||
|  |     register, | ||||||
|  |     control, | ||||||
|  |     watch, | ||||||
|  |     formState: { errors }, | ||||||
|  |     setValue, | ||||||
|  |     handleSubmit, | ||||||
|  |   } = useForm(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> | ||||||
|  |       <Box sx={{ display: "flex", flex: 1, gap: 2 }}> | ||||||
|  |         {/* Форма создания */} | ||||||
|  |         <Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}> | ||||||
|  |           <Box | ||||||
|  |             component="form" | ||||||
|  |             sx={{ flex: 1, display: "flex", flexDirection: "column" }} | ||||||
|  |             autoComplete="off" | ||||||
|  |           > | ||||||
|  |             <TextField | ||||||
|  |               {...register("Name", { | ||||||
|  |                 required: "Это поле является обязательным", | ||||||
|  |               })} | ||||||
|  |               error={!!(errors as any)?.name} | ||||||
|  |               helperText={(errors as any)?.name?.message} | ||||||
|  |               margin="normal" | ||||||
|  |               fullWidth | ||||||
|  |               InputLabelProps={{ shrink: true }} | ||||||
|  |               type="text" | ||||||
|  |               label={"Название *"} | ||||||
|  |               name="Name" | ||||||
|  |             /> | ||||||
|  |           </Box> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |     </Create> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										3
									
								
								src/pages/snapshot/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/pages/snapshot/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export * from "./create"; | ||||||
|  | export * from "./list"; | ||||||
|  | export * from "./show"; | ||||||
							
								
								
									
										149
									
								
								src/pages/snapshot/list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								src/pages/snapshot/list.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | |||||||
|  | import React from "react"; | ||||||
|  | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
|  | import { | ||||||
|  |   DeleteButton, | ||||||
|  |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import { Stack } from "@mui/material"; | ||||||
|  | import { CustomDataGrid } from "@components"; | ||||||
|  | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { useMany } from "@refinedev/core"; | ||||||
|  | import { DatabaseBackup } from "lucide-react"; | ||||||
|  | import axios from "axios"; | ||||||
|  | import { TOKEN_KEY } from "../../providers/authProvider"; | ||||||
|  | import { toast } from "react-toastify"; | ||||||
|  | import { useNotification } from "@refinedev/core"; | ||||||
|  |  | ||||||
|  | export const SnapshotList = observer(() => { | ||||||
|  |   const notification = useNotification(); | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "snapshots", | ||||||
|  |     hasPagination: false, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Получаем список уникальных ParentID | ||||||
|  |   const parentIds = React.useMemo(() => { | ||||||
|  |     return ( | ||||||
|  |       dataGridProps?.rows | ||||||
|  |         ?.map((row: any) => row.ParentID) | ||||||
|  |         .filter((id) => id !== null && id !== undefined) | ||||||
|  |         .filter((value, index, self) => self.indexOf(value) === index) || [] | ||||||
|  |     ); | ||||||
|  |   }, [dataGridProps?.rows]); | ||||||
|  |  | ||||||
|  |   // Загружаем родительские снапшоты | ||||||
|  |   const { data: parentsData } = useMany({ | ||||||
|  |     resource: "snapshots", | ||||||
|  |     ids: parentIds, | ||||||
|  |     queryOptions: { | ||||||
|  |       enabled: parentIds.length > 0, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Создаем мапу ID → Name | ||||||
|  |   const parentNameMap = React.useMemo(() => { | ||||||
|  |     const map: Record<number, string> = {}; | ||||||
|  |     parentsData?.data?.forEach((parent) => { | ||||||
|  |       map[parent.ID] = parent.Name; | ||||||
|  |     }); | ||||||
|  |     return map; | ||||||
|  |   }, [parentsData]); | ||||||
|  |  | ||||||
|  |   const handleBackup = async (id: number) => { | ||||||
|  |     try { | ||||||
|  |       const response = await axios.post( | ||||||
|  |         `${import.meta.env.VITE_KRBL_API}/snapshots/${id}/restore`, | ||||||
|  |         {}, | ||||||
|  |         { | ||||||
|  |           headers: { | ||||||
|  |             Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, | ||||||
|  |           }, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (notification && typeof notification.open === "function") { | ||||||
|  |         notification.open({ | ||||||
|  |           message: "Cнапшот восстановлен", | ||||||
|  |           type: "success", | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       if (notification && typeof notification.open === "function") { | ||||||
|  |         notification.open({ | ||||||
|  |           message: "Ошибка при восстановлении снимка", | ||||||
|  |           type: "error", | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const columns = React.useMemo<GridColDef[]>( | ||||||
|  |     () => [ | ||||||
|  |       { | ||||||
|  |         field: "Name", | ||||||
|  |         headerName: "Название", | ||||||
|  |         type: "string", | ||||||
|  |         minWidth: 150, | ||||||
|  |         flex: 1, | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "ParentID", | ||||||
|  |         headerName: "Родитель", | ||||||
|  |         minWidth: 150, | ||||||
|  |         flex: 1, | ||||||
|  |         renderCell: ({ value }) => parentNameMap[value] || "—", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "actions", | ||||||
|  |         headerName: "Действия", | ||||||
|  |         minWidth: 150, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "center", | ||||||
|  |         headerAlign: "center", | ||||||
|  |         sortable: false, | ||||||
|  |         filterable: false, | ||||||
|  |         disableColumnMenu: true, | ||||||
|  |         renderCell: function render({ row }) { | ||||||
|  |           return ( | ||||||
|  |             <> | ||||||
|  |               <button | ||||||
|  |                 className="backup-button" | ||||||
|  |                 onClick={() => handleBackup(row.ID)} | ||||||
|  |               > | ||||||
|  |                 <DatabaseBackup /> | ||||||
|  |               </button> | ||||||
|  |               <ShowButton hideText recordItemId={row.ID} /> | ||||||
|  |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.ID} | ||||||
|  |               /> | ||||||
|  |             </> | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     ], | ||||||
|  |     [parentNameMap] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <List> | ||||||
|  |       <Stack gap={2.5}> | ||||||
|  |         <CustomDataGrid | ||||||
|  |           {...dataGridProps} | ||||||
|  |           columns={columns} | ||||||
|  |           localeText={localeText} | ||||||
|  |           getRowId={(row: any) => row.ID} | ||||||
|  |         /> | ||||||
|  |       </Stack> | ||||||
|  |     </List> | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										26
									
								
								src/pages/snapshot/show.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/pages/snapshot/show.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | import { Stack, Typography } from "@mui/material"; | ||||||
|  | import { useShow } from "@refinedev/core"; | ||||||
|  | import { Show, TextFieldComponent } from "@refinedev/mui"; | ||||||
|  |  | ||||||
|  | export const SnapshotShow = () => { | ||||||
|  |   const { query } = useShow({}); | ||||||
|  |   const { data, isLoading } = query; | ||||||
|  |   const record = data?.data; | ||||||
|  |  | ||||||
|  |   const fields = [{ label: "Название", data: "Name" }]; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Show isLoading={isLoading} canEdit={false}> | ||||||
|  |       <Stack gap={4}> | ||||||
|  |         {fields.map(({ label, data }) => ( | ||||||
|  |           <Stack key={data} gap={1}> | ||||||
|  |             <Typography variant="body1" fontWeight="bold"> | ||||||
|  |               {label} | ||||||
|  |             </Typography> | ||||||
|  |             <TextFieldComponent value={record?.[data]} /> | ||||||
|  |           </Stack> | ||||||
|  |         ))} | ||||||
|  |       </Stack> | ||||||
|  |     </Show> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -1,183 +1,314 @@ | |||||||
| import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material' | import { | ||||||
| import {Create, useAutocomplete} from '@refinedev/mui' |   Autocomplete, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   Box, | ||||||
| import {Controller} from 'react-hook-form' |   TextField, | ||||||
|  |   Typography, | ||||||
|  |   FormControlLabel, | ||||||
|  |   Checkbox, | ||||||
|  |   Grid, | ||||||
|  |   Paper, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Create, useAutocomplete } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| const TRANSFER_FIELDS = [ | const TRANSFER_FIELDS = [ | ||||||
|   {name: 'bus', label: 'Автобус'}, |   { name: "bus", label: "Автобус" }, | ||||||
|   {name: 'metro_blue', label: 'Метро (синяя)'}, |   { name: "metro_blue", label: "Метро (синяя)" }, | ||||||
|   {name: 'metro_green', label: 'Метро (зеленая)'}, |   { name: "metro_green", label: "Метро (зеленая)" }, | ||||||
|   {name: 'metro_orange', label: 'Метро (оранжевая)'}, |   { name: "metro_orange", label: "Метро (оранжевая)" }, | ||||||
|   {name: 'metro_purple', label: 'Метро (фиолетовая)'}, |   { name: "metro_purple", label: "Метро (фиолетовая)" }, | ||||||
|   {name: 'metro_red', label: 'Метро (красная)'}, |   { name: "metro_red", label: "Метро (красная)" }, | ||||||
|   {name: 'train', label: 'Электричка'}, |   { name: "train", label: "Электричка" }, | ||||||
|   {name: 'tram', label: 'Трамвай'}, |   { name: "tram", label: "Трамвай" }, | ||||||
|   {name: 'trolleybus', label: 'Троллейбус'}, |   { name: "trolleybus", label: "Троллейбус" }, | ||||||
| ] | ]; | ||||||
|  |  | ||||||
| export const StationCreate = () => { | export const StationCreate = () => { | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     refineCore: {formLoading}, |     refineCore: { formLoading }, | ||||||
|     register, |     register, | ||||||
|  |     setValue, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     getValues, | ||||||
|  |     watch, | ||||||
|  |     formState: { errors }, | ||||||
|   } = useForm({ |   } = useForm({ | ||||||
|     refineCoreProps: { |     refineCoreProps: { | ||||||
|       resource: 'station/', |       resource: "station", | ||||||
|     }, |     }, | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ |   const [coordinates, setCoordinates] = useState(""); | ||||||
|     resource: 'city', |  | ||||||
|  |   const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     setCoordinates(e.target.value); | ||||||
|  |     const [lat, lon] = e.target.value | ||||||
|  |       .replace(/,/g, "") // Remove all commas from the string | ||||||
|  |       .split(" ") | ||||||
|  |       .map((s) => s.trim()); | ||||||
|  |     console.log(lat, lon); | ||||||
|  |     setValue("latitude", lat); | ||||||
|  |     setValue("longitude", lon); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const directions = [ | ||||||
|  |     { | ||||||
|  |       label: "Прямой", | ||||||
|  |       value: true, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: "Обратный", | ||||||
|  |       value: false, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const [routeDirection, setRouteDirection] = useState(false); | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "city", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> |     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('name', { |           {...register("name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.name} |           error={!!(errors as any)?.name} | ||||||
|           helperText={(errors as any)?.name?.message} |           helperText={(errors as any)?.name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Название *'} |           label={"Название *"} | ||||||
|           name="name" |           name="name" | ||||||
|         /> |         /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('system_name', { |           {...register("system_name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.system_name} |           error={!!(errors as any)?.system_name} | ||||||
|           helperText={(errors as any)?.system_name?.message} |           helperText={(errors as any)?.system_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Системное название *'} |           label={"Системное название *"} | ||||||
|           name="system_name" |           name="system_name" | ||||||
|         /> |         /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('description', { |           {...register("address", { | ||||||
|  |             // required: 'Это поле является обязательным', | ||||||
|  |           })} | ||||||
|  |           error={!!(errors as any)?.address} | ||||||
|  |           helperText={(errors as any)?.address?.message} | ||||||
|  |           margin="normal" | ||||||
|  |           fullWidth | ||||||
|  |           InputLabelProps={{ shrink: true }} | ||||||
|  |           type="text" | ||||||
|  |           label={"Адрес"} | ||||||
|  |           name="address" | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         <TextField | ||||||
|  |           {...register("description", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.description} |           error={!!(errors as any)?.description} | ||||||
|           helperText={(errors as any)?.description?.message} |           helperText={(errors as any)?.description?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Описание'} |           label={"Описание"} | ||||||
|           name="description" |           name="description" | ||||||
|         /> |         /> | ||||||
|         <TextField |         <input | ||||||
|           {...register('latitude', { |           type="hidden" | ||||||
|             required: 'Это поле является обязательным', |           {...register("direction", { | ||||||
|             valueAsNumber: true, |             value: routeDirection, | ||||||
|           })} |           })} | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         <Autocomplete | ||||||
|  |           options={directions} | ||||||
|  |           defaultValue={directions.find((el) => el.value == false)} | ||||||
|  |           onChange={(_, element) => { | ||||||
|  |             if (element) { | ||||||
|  |               setValue("direction", element.value); | ||||||
|  |               setRouteDirection(element.value); | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|  |           renderInput={(params) => ( | ||||||
|  |             <TextField | ||||||
|  |               {...params} | ||||||
|  |               label="Прямой/обратный маршрут" | ||||||
|  |               margin="normal" | ||||||
|  |               variant="outlined" | ||||||
|  |               error={!!errors.arms} | ||||||
|  |               helperText={(errors as any)?.arms?.message} | ||||||
|  |               required | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |         /> | ||||||
|  |         <TextField | ||||||
|  |           value={coordinates} | ||||||
|  |           onChange={handleCoordinatesChange} | ||||||
|           error={!!(errors as any)?.latitude} |           error={!!(errors as any)?.latitude} | ||||||
|           helperText={(errors as any)?.latitude?.message} |           helperText={(errors as any)?.latitude?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="text" | ||||||
|           label={'Широта *'} |           label={"Координаты *"} | ||||||
|           name="latitude" |  | ||||||
|         /> |         /> | ||||||
|         <TextField |  | ||||||
|           {...register('longitude', { |         <input | ||||||
|             required: 'Это поле является обязательным', |           type="hidden" | ||||||
|             valueAsNumber: true, |           {...register("latitude", { | ||||||
|  |             value: coordinates.split(",")[0], | ||||||
|  |             setValueAs: (value) => { | ||||||
|  |               if (value === "") { | ||||||
|  |                 return 0; | ||||||
|  |               } | ||||||
|  |               return Number(value); | ||||||
|  |             }, | ||||||
|  |           })} | ||||||
|  |         /> | ||||||
|  |         <input | ||||||
|  |           type="hidden" | ||||||
|  |           {...register("longitude", { | ||||||
|  |             value: coordinates.split(",")[1], | ||||||
|  |             setValueAs: (value) => { | ||||||
|  |               if (value === "") { | ||||||
|  |                 return 0; | ||||||
|  |               } | ||||||
|  |               return Number(value); | ||||||
|  |             }, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.longitude} |  | ||||||
|           helperText={(errors as any)?.longitude?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Долгота *'} |  | ||||||
|           name="longitude" |  | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="city_id" |           name="city_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...cityAutocompleteProps} |               {...cityAutocompleteProps} | ||||||
|               value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 cityAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.name : '' |                 return item ? item.name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите город" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.city_id} | ||||||
|  |                   helperText={(errors as any)?.city_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <Box sx={{ visibility: "hidden" }}> | ||||||
|           {...register('offset_x', { |           <TextField | ||||||
|             // required: 'Это поле является обязательным', |             {...register("offset_x", { | ||||||
|           })} |               // required: 'Это поле является обязательным', | ||||||
|           error={!!(errors as any)?.offset_x} |               setValueAs: (value) => { | ||||||
|           helperText={(errors as any)?.offset_x?.message} |                 if (value === "") { | ||||||
|           margin="normal" |                   return 0; | ||||||
|           fullWidth |                 } | ||||||
|           InputLabelProps={{shrink: true}} |               }, | ||||||
|           type="number" |             })} | ||||||
|           label={'Смещение (X)'} |             error={!!(errors as any)?.offset_x} | ||||||
|           name="offset_x" |             helperText={(errors as any)?.offset_x?.message} | ||||||
|         /> |             margin="normal" | ||||||
|  |             fullWidth | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|  |             type="number" | ||||||
|  |             label={"Смещение (X)"} | ||||||
|  |             name="offset_x" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|         <TextField |           <TextField | ||||||
|           {...register('offset_y', { |             {...register("offset_y", { | ||||||
|             // required: 'Это поле является обязательным', |               setValueAs: (value) => { | ||||||
|           })} |                 if (value === "") { | ||||||
|           error={!!(errors as any)?.offset_y} |                   return 0; | ||||||
|           helperText={(errors as any)?.offset_y?.message} |                 } | ||||||
|           margin="normal" |               }, | ||||||
|           fullWidth |               // required: 'Это поле является обязательным', | ||||||
|           InputLabelProps={{shrink: true}} |             })} | ||||||
|           type="number" |             error={!!(errors as any)?.offset_y} | ||||||
|           label={'Смещение (Y)'} |             helperText={(errors as any)?.offset_y?.message} | ||||||
|           name="offset_y" |             margin="normal" | ||||||
|         /> |             fullWidth | ||||||
|  |             InputLabelProps={{ shrink: true }} | ||||||
|         {/* Группа полей пересадок */} |             type="number" | ||||||
|         <Paper sx={{p: 2, mt: 2}}> |             label={"Смещение (Y)"} | ||||||
|           <Typography variant="h6" gutterBottom> |             name="offset_y" | ||||||
|             Пересадки |           /> | ||||||
|           </Typography> |         </Box> | ||||||
|           <Grid container spacing={2}> |  | ||||||
|             {TRANSFER_FIELDS.map((field) => ( |  | ||||||
|               <Grid item xs={12} sm={6} md={4} key={field.name}> |  | ||||||
|                 <TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} /> |  | ||||||
|               </Grid> |  | ||||||
|             ))} |  | ||||||
|           </Grid> |  | ||||||
|         </Paper> |  | ||||||
|       </Box> |       </Box> | ||||||
|  |  | ||||||
|  |       {/* Группа полей пересадок */} | ||||||
|  |       <Paper hidden sx={{ p: 2, mt: 2 }}> | ||||||
|  |         <Typography variant="h6" gutterBottom> | ||||||
|  |           Пересадки | ||||||
|  |         </Typography> | ||||||
|  |         <Grid container spacing={2}> | ||||||
|  |           {TRANSFER_FIELDS.map((field) => ( | ||||||
|  |             <Grid item xs={12} sm={6} md={4} key={field.name}> | ||||||
|  |               <TextField | ||||||
|  |                 {...register(`transfers.${field.name}`)} | ||||||
|  |                 error={!!(errors as any)?.transfers?.[field.name]} | ||||||
|  |                 helperText={(errors as any)?.transfers?.[field.name]?.message} | ||||||
|  |                 margin="normal" | ||||||
|  |                 fullWidth | ||||||
|  |                 InputLabelProps={{ shrink: true }} | ||||||
|  |                 type="text" | ||||||
|  |                 label={field.label} | ||||||
|  |                 name={`transfers.${field.name}`} | ||||||
|  |               /> | ||||||
|  |             </Grid> | ||||||
|  |           ))} | ||||||
|  |         </Grid> | ||||||
|  |       </Paper> | ||||||
|     </Create> |     </Create> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,183 +1,377 @@ | |||||||
| import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material' | import { | ||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' |   Autocomplete, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   Box, | ||||||
| import {Controller} from 'react-hook-form' |   TextField, | ||||||
|  |   Typography, | ||||||
|  |   FormControlLabel, | ||||||
|  |   Paper, | ||||||
|  |   Grid, | ||||||
|  |   Checkbox, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Edit, useAutocomplete } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| import {useParams} from 'react-router' | import { useParams } from "react-router"; | ||||||
| import {LinkedItems} from '../../components/LinkedItems' | import { LinkedItems } from "../../components/LinkedItems"; | ||||||
| import {type SightItem, sightFields} from './types' | import { type SightItem, sightFields } from "./types"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore } from "../../store/LanguageStore"; | ||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { LanguageSwitch } from "../../components/LanguageSwitch/index"; | ||||||
|  |  | ||||||
| const TRANSFER_FIELDS = [ | const TRANSFER_FIELDS = [ | ||||||
|   {name: 'bus', label: 'Автобус'}, |   { name: "bus", label: "Автобус" }, | ||||||
|   {name: 'metro_blue', label: 'Метро (синяя)'}, |   { name: "metro_blue", label: "Метро (синяя)" }, | ||||||
|   {name: 'metro_green', label: 'Метро (зеленая)'}, |   { name: "metro_green", label: "Метро (зеленая)" }, | ||||||
|   {name: 'metro_orange', label: 'Метро (оранжевая)'}, |   { name: "metro_orange", label: "Метро (оранжевая)" }, | ||||||
|   {name: 'metro_purple', label: 'Метро (фиолетовая)'}, |   { name: "metro_purple", label: "Метро (фиолетовая)" }, | ||||||
|   {name: 'metro_red', label: 'Метро (красная)'}, |   { name: "metro_red", label: "Метро (красная)" }, | ||||||
|   {name: 'train', label: 'Электричка'}, |   { name: "train", label: "Электричка" }, | ||||||
|   {name: 'tram', label: 'Трамвай'}, |   { name: "tram", label: "Трамвай" }, | ||||||
|   {name: 'trolleybus', label: 'Троллейбус'}, |   { name: "trolleybus", label: "Троллейбус" }, | ||||||
| ] | ]; | ||||||
|  |  | ||||||
|  | export const StationEdit = observer(() => { | ||||||
|  |   const { language, setLanguageAction } = languageStore; | ||||||
|  |   const [stationData, setStationData] = useState({ | ||||||
|  |     ru: { | ||||||
|  |       name: "", | ||||||
|  |       system_name: "", | ||||||
|  |       description: "", | ||||||
|  |       address: "", | ||||||
|  |       latitude: 0, | ||||||
|  |       longitude: 0, | ||||||
|  |     }, | ||||||
|  |     en: { | ||||||
|  |       name: "", | ||||||
|  |       system_name: "", | ||||||
|  |       description: "", | ||||||
|  |       address: "", | ||||||
|  |       latitude: 0, | ||||||
|  |       longitude: 0, | ||||||
|  |     }, | ||||||
|  |     zh: { | ||||||
|  |       name: "", | ||||||
|  |       system_name: "", | ||||||
|  |       description: "", | ||||||
|  |       address: "", | ||||||
|  |       latitude: 0, | ||||||
|  |       longitude: 0, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleLanguageChange = () => { | ||||||
|  |     setStationData((prevData) => ({ | ||||||
|  |       ...prevData, | ||||||
|  |       [language]: { | ||||||
|  |         name: watch("name") ?? "", | ||||||
|  |         system_name: watch("system_name") ?? "", | ||||||
|  |         description: watch("description") ?? "", | ||||||
|  |         address: watch("address") ?? "", | ||||||
|  |         latitude: Number(watch("latitude")) || 0, | ||||||
|  |         longitude: Number(watch("longitude")) || 0, | ||||||
|  |       }, | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const [coordinatesPreview, setCoordinatesPreview] = useState({ | ||||||
|  |     latitude: "", | ||||||
|  |     longitude: "", | ||||||
|  |   }); | ||||||
|  |  | ||||||
| export const StationEdit = () => { |  | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     getValues, | ||||||
|   } = useForm({}) |     setValue, | ||||||
|  |     watch, | ||||||
|  |     formState: { errors }, | ||||||
|  |   } = useForm({ | ||||||
|  |     refineCoreProps: { | ||||||
|  |       meta: { | ||||||
|  |         headers: { "Accept-Language": language }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {id: stationId} = useParams<{id: string}>() |   const directions = [ | ||||||
|  |     { | ||||||
|  |       label: "Прямой", | ||||||
|  |       value: true, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: "Обратный", | ||||||
|  |       value: false, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ |   const directionContent = watch("direction"); | ||||||
|     resource: 'city', |   const [routeDirection, setRouteDirection] = useState(false); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (directionContent) { | ||||||
|  |       setRouteDirection(directionContent); | ||||||
|  |     } | ||||||
|  |   }, [directionContent]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (stationData[language as keyof typeof stationData]?.name) { | ||||||
|  |       setValue("name", stationData[language as keyof typeof stationData]?.name); | ||||||
|  |     } | ||||||
|  |     if (stationData[language as keyof typeof stationData]?.system_name) { | ||||||
|  |       setValue( | ||||||
|  |         "system_name", | ||||||
|  |         stationData[language as keyof typeof stationData]?.system_name || "" | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     if (stationData[language as keyof typeof stationData]?.description) { | ||||||
|  |       setValue( | ||||||
|  |         "description", | ||||||
|  |         stationData[language as keyof typeof stationData]?.description || "" | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     if ( | ||||||
|  |       stationData[language as keyof typeof stationData]?.latitude !== undefined | ||||||
|  |     ) { | ||||||
|  |       setValue( | ||||||
|  |         "latitude", | ||||||
|  |         stationData[language as keyof typeof stationData]?.latitude || 0 | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     if ( | ||||||
|  |       stationData[language as keyof typeof stationData]?.longitude !== undefined | ||||||
|  |     ) { | ||||||
|  |       setValue( | ||||||
|  |         "longitude", | ||||||
|  |         stationData[language as keyof typeof stationData]?.longitude || 0 | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   }, [language, stationData, setValue]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setLanguageAction("ru"); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const [coordinates, setCoordinates] = useState(""); | ||||||
|  |  | ||||||
|  |   const { id: stationId } = useParams<{ id: string }>(); | ||||||
|  |  | ||||||
|  |   const latitudeContent = watch("latitude"); | ||||||
|  |   const longitudeContent = watch("longitude"); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (latitudeContent && longitudeContent) { | ||||||
|  |       setCoordinates(`${latitudeContent} ${longitudeContent}`); | ||||||
|  |     } | ||||||
|  |   }, [latitudeContent, longitudeContent]); | ||||||
|  |  | ||||||
|  |   const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     setCoordinates(e.target.value); | ||||||
|  |     if (e.target.value) { | ||||||
|  |       const [lat, lon] = e.target.value | ||||||
|  |         .replace(/,/g, "") // Remove all commas from the string | ||||||
|  |         .split(" ") | ||||||
|  |         .map((s) => s.trim()); | ||||||
|  |       setCoordinates(`${lat ?? 0} ${lon ?? 0}`); | ||||||
|  |       setValue("latitude", lat ?? 0); | ||||||
|  |       setValue("longitude", lon ?? 0); | ||||||
|  |     } else { | ||||||
|  |       setCoordinates(""); | ||||||
|  |       setValue("latitude", ""); | ||||||
|  |       setValue("longitude", ""); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ | ||||||
|  |     resource: "city", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |  | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": "ru", | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     queryOptions: { | ||||||
|  |       queryKey: ["city"], | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const cityId = watch("city_id"); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|  |         <LanguageSwitch action={handleLanguageChange} /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('name', { |           {...register("name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.name} |           error={!!(errors as any)?.name} | ||||||
|           helperText={(errors as any)?.name?.message} |           helperText={(errors as any)?.name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Название *'} |           label={"Название *"} | ||||||
|           name="name" |           name="name" | ||||||
|         /> |         /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('system_name', { |           {...register("system_name", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.system_name} |           error={!!(errors as any)?.system_name} | ||||||
|           helperText={(errors as any)?.system_name?.message} |           helperText={(errors as any)?.system_name?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Системное название *'} |           label={"Системное название *"} | ||||||
|           name="system_name" |           name="system_name" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|  |         <input | ||||||
|  |           type="hidden" | ||||||
|  |           {...register("direction", { value: routeDirection })} | ||||||
|  |         /> | ||||||
|  |         <Autocomplete | ||||||
|  |           options={directions} | ||||||
|  |           value={directions.find((el) => el.value == routeDirection)} | ||||||
|  |           onChange={(_, element) => { | ||||||
|  |             if (element) { | ||||||
|  |               setValue("direction", element.value); | ||||||
|  |               setRouteDirection(element.value); | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|  |           renderInput={(params) => ( | ||||||
|  |             <TextField | ||||||
|  |               {...params} | ||||||
|  |               label="Прямой/обратный маршрут" | ||||||
|  |               margin="normal" | ||||||
|  |               variant="outlined" | ||||||
|  |               error={!!errors.direction} | ||||||
|  |               helperText={(errors as any)?.direction?.message} | ||||||
|  |               required | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |         /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('description', { |           {...register("description", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.description} |           error={!!(errors as any)?.description} | ||||||
|           helperText={(errors as any)?.description?.message} |           helperText={(errors as any)?.description?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Описание'} |           label={"Описание"} | ||||||
|           name="description" |           name="description" | ||||||
|         /> |         /> | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('latitude', { |           {...register("address", { | ||||||
|             required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|             valueAsNumber: true, |  | ||||||
|           })} |           })} | ||||||
|  |           error={!!(errors as any)?.address} | ||||||
|  |           helperText={(errors as any)?.address?.message} | ||||||
|  |           margin="normal" | ||||||
|  |           fullWidth | ||||||
|  |           InputLabelProps={{ shrink: true }} | ||||||
|  |           type="text" | ||||||
|  |           label={"Адрес"} | ||||||
|  |           name="address" | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         <TextField | ||||||
|  |           value={coordinates} | ||||||
|  |           onChange={handleCoordinatesChange} | ||||||
|           error={!!(errors as any)?.latitude} |           error={!!(errors as any)?.latitude} | ||||||
|           helperText={(errors as any)?.latitude?.message} |           helperText={(errors as any)?.latitude?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="text" | ||||||
|           label={'Широта *'} |           label={"Координаты *"} | ||||||
|           name="latitude" |  | ||||||
|         /> |         /> | ||||||
|         <TextField |         <input | ||||||
|           {...register('longitude', { |           type="hidden" | ||||||
|             required: 'Это поле является обязательным', |           {...register("latitude", { | ||||||
|             valueAsNumber: true, |             valueAsNumber: true, | ||||||
|  |             value: Number(coordinates.split(" ")[0]), | ||||||
|  |             setValueAs: (value) => { | ||||||
|  |               if (value === "") { | ||||||
|  |                 return 0; | ||||||
|  |               } | ||||||
|  |               return Number(value); | ||||||
|  |             }, | ||||||
|  |           })} | ||||||
|  |         /> | ||||||
|  |         <input | ||||||
|  |           type="hidden" | ||||||
|  |           {...register("longitude", { | ||||||
|  |             valueAsNumber: true, | ||||||
|  |             value: Number(coordinates.split(" ")[1]), | ||||||
|  |             setValueAs: (value) => { | ||||||
|  |               if (value === "") { | ||||||
|  |                 return 0; | ||||||
|  |               } | ||||||
|  |               return Number(value); | ||||||
|  |             }, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.longitude} |  | ||||||
|           helperText={(errors as any)?.longitude?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Долгота *'} |  | ||||||
|           name="longitude" |  | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="city_id" |           name="city_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...cityAutocompleteProps} |               {...cityAutocompleteProps} | ||||||
|               value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 cityAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.name : '' |                 return item ? item.name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите город" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.city_id} | ||||||
|  |                   helperText={(errors as any)?.city_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('offset_x', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.offset_x} |  | ||||||
|           helperText={(errors as any)?.offset_x?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Смещение (X)'} |  | ||||||
|           name="offset_x" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         <TextField |  | ||||||
|           {...register('offset_y', { |  | ||||||
|             // required: 'Это поле является обязательным', |  | ||||||
|           })} |  | ||||||
|           error={!!(errors as any)?.offset_y} |  | ||||||
|           helperText={(errors as any)?.offset_y?.message} |  | ||||||
|           margin="normal" |  | ||||||
|           fullWidth |  | ||||||
|           InputLabelProps={{shrink: true}} |  | ||||||
|           type="number" |  | ||||||
|           label={'Смещение (Y)'} |  | ||||||
|           name="offset_y" |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         {/* Группа полей пересадок */} |  | ||||||
|         <Paper sx={{p: 2, mt: 2}}> |  | ||||||
|           <Typography variant="h6" gutterBottom> |  | ||||||
|             Пересадки |  | ||||||
|           </Typography> |  | ||||||
|           <Grid container spacing={2}> |  | ||||||
|             {TRANSFER_FIELDS.map((field) => ( |  | ||||||
|               <Grid item xs={12} sm={6} md={4} key={field.name}> |  | ||||||
|                 <TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} /> |  | ||||||
|               </Grid> |  | ||||||
|             ))} |  | ||||||
|           </Grid> |  | ||||||
|         </Paper> |  | ||||||
|       </Box> |       </Box> | ||||||
|  |  | ||||||
|       {stationId && ( |       {stationId && ( | ||||||
| @@ -188,8 +382,10 @@ export const StationEdit = () => { | |||||||
|           childResource="sight" |           childResource="sight" | ||||||
|           fields={sightFields} |           fields={sightFields} | ||||||
|           title="достопримечательности" |           title="достопримечательности" | ||||||
|  |           dragAllowed={false} | ||||||
|  |           cityId={cityId} | ||||||
|         /> |         /> | ||||||
|       )} |       )} | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,126 +1,180 @@ | |||||||
| import React from 'react' | import React, { useEffect, useMemo } from "react"; | ||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import {Stack} from '@mui/material' |   DeleteButton, | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' |   EditButton, | ||||||
| import {localeText} from '../../locales/ru/localeText' |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import { Stack, Typography } from "@mui/material"; | ||||||
|  | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
|  | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { cityStore } from "../../store/CityStore"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore } from "../../store/LanguageStore"; | ||||||
|  |  | ||||||
| export const StationList = () => { | export const StationList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({resource: 'station/'}) |   const { city_id } = cityStore; | ||||||
|  |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "station", | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     filters: { | ||||||
|  |       permanent: [ | ||||||
|  |         { | ||||||
|  |           field: "cityID", | ||||||
|  |           operator: "eq", | ||||||
|  |           value: city_id === "0" ? null : city_id, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 70, |         minWidth: 70, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'name', |         field: "name", | ||||||
|         headerName: 'Название', |         headerName: "Название", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 300, |         minWidth: 300, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'system_name', |  | ||||||
|         headerName: 'Системное название', |  | ||||||
|         type: 'string', |  | ||||||
|         minWidth: 200, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'latitude', |  | ||||||
|         headerName: 'Широта', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 150, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'longitude', |  | ||||||
|         headerName: 'Долгота', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 150, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'city_id', |  | ||||||
|         headerName: 'ID города', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'offset_x', |  | ||||||
|         headerName: 'Смещение (X)', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'offset_y', |  | ||||||
|         headerName: 'Смещение (Y)', |  | ||||||
|         type: 'number', |  | ||||||
|         minWidth: 120, |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         field: 'description', |  | ||||||
|         headerName: 'Описание', |  | ||||||
|         type: 'string', |  | ||||||
|         display: 'flex', |  | ||||||
|         align: 'left', |  | ||||||
|         headerAlign: 'left', |  | ||||||
|         flex: 1, |         flex: 1, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "system_name", | ||||||
|         headerName: 'Действия', |         headerName: "Системное название", | ||||||
|         cellClassName: 'station-actions', |         type: "string", | ||||||
|         align: 'right', |         minWidth: 200, | ||||||
|         headerAlign: 'center', |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |         flex: 1, | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "direction", | ||||||
|  |         headerName: "Направление", | ||||||
|  |         type: "boolean", | ||||||
|  |         minWidth: 200, | ||||||
|  |         display: "flex", | ||||||
|  |  | ||||||
|  |         renderCell: ({ value }) => ( | ||||||
|  |           <Typography style={{ color: value ? "#48989f" : "#7f6b58" }}> | ||||||
|  |             {value ? "прямой" : "обратный"} | ||||||
|  |           </Typography> | ||||||
|  |         ), | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "latitude", | ||||||
|  |         headerName: "Широта", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 150, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "longitude", | ||||||
|  |         headerName: "Долгота", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 150, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "city_id", | ||||||
|  |         headerName: "ID города", | ||||||
|  |         type: "number", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "offset_x", | ||||||
|  |         headerName: "Смещение (X)", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         field: "offset_y", | ||||||
|  |         headerName: "Смещение (Y)", | ||||||
|  |         type: "number", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |       }, | ||||||
|  |       // { | ||||||
|  |       //   field: "description", | ||||||
|  |       //   headerName: "Описание", | ||||||
|  |       //   type: "string", | ||||||
|  |       //   display: "flex", | ||||||
|  |       //   align: "left", | ||||||
|  |       //   headerAlign: "left", | ||||||
|  |       //   flex: 1, | ||||||
|  |       // }, | ||||||
|  |       { | ||||||
|  |         field: "actions", | ||||||
|  |         headerName: "Действия", | ||||||
|  |         cellClassName: "station-actions", | ||||||
|  |         align: "right", | ||||||
|  |         headerAlign: "center", | ||||||
|  |         minWidth: 120, | ||||||
|  |         display: "flex", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               <ShowButton hideText recordItemId={row.id} /> | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List key={city_id}> | ||||||
|       <Stack gap={2.5}> |       <Stack gap={2.5}> | ||||||
|         <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates /> |         <CustomDataGrid | ||||||
|  |           {...dataGridProps} | ||||||
|  |           columns={columns} | ||||||
|  |           languageEnabled | ||||||
|  |           localeText={localeText} | ||||||
|  |           getRowId={(row: any) => row.id} | ||||||
|  |           hasCoordinates | ||||||
|  |         /> | ||||||
|       </Stack> |       </Stack> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,23 +1,31 @@ | |||||||
| import {useShow} from '@refinedev/core' | import { useShow } from "@refinedev/core"; | ||||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||||
| import {Stack, Typography} from '@mui/material' | import { Box, Stack, Typography } from "@mui/material"; | ||||||
| import {LinkedItems} from '../../components/LinkedItems' | import { LinkedItems } from "../../components/LinkedItems"; | ||||||
| import {type SightItem, sightFields, stationFields} from './types' | import { type SightItem, sightFields, stationFields } from "./types"; | ||||||
|  |  | ||||||
| export const StationShow = () => { | export const StationShow = () => { | ||||||
|   const {query} = useShow({}) |   const { query } = useShow({}); | ||||||
|   const {data, isLoading} = query |   const { data, isLoading } = query; | ||||||
|   const record = data?.data |   const record = data?.data; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Show isLoading={isLoading}> |     <Show isLoading={isLoading}> | ||||||
|       <Stack gap={4}> |       <Stack gap={4}> | ||||||
|         {stationFields.map(({label, data}) => ( |         {stationFields.map(({ label, data }) => ( | ||||||
|           <Stack key={data} gap={1}> |           <Stack key={data} gap={1}> | ||||||
|             <Typography variant="body1" fontWeight="bold"> |             <Typography variant="body1" fontWeight="bold"> | ||||||
|               {label} |               {label} | ||||||
|  |               {label === "Системное название" && ( | ||||||
|  |                 <Box> | ||||||
|  |                   <TextField | ||||||
|  |                     value={record?.direction ? "(Прямой)" : "(Обратный)"} | ||||||
|  |                   /> | ||||||
|  |                 </Box> | ||||||
|  |               )} | ||||||
|             </Typography> |             </Typography> | ||||||
|             <TextField value={record?.[data] || ''} /> |  | ||||||
|  |             <TextField value={record?.[data] || ""} /> | ||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|  |  | ||||||
| @@ -33,5 +41,5 @@ export const StationShow = () => { | |||||||
|         )} |         )} | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
| @@ -1,44 +1,46 @@ | |||||||
| import React from 'react' | import React from "react"; | ||||||
|  |  | ||||||
| export type StationItem = { | export type StationItem = { | ||||||
|   id: number |   id: number; | ||||||
|   name: string |   name: string; | ||||||
|   description: string |   description: string; | ||||||
|   latitude: number |   latitude: number; | ||||||
|   longitude: number |   longitude: number; | ||||||
|   [key: string]: string | number |   [key: string]: string | number; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export type SightItem = { | export type SightItem = { | ||||||
|   id: number |   id: number; | ||||||
|   name: string |   name: string; | ||||||
|   latitude: number |   latitude: number; | ||||||
|   longitude: number |   longitude: number; | ||||||
|   city_id: number |   city_id: number; | ||||||
|   city: string |   city: string; | ||||||
|   [key: string]: string | number |   [key: string]: string | number; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export type FieldType<T> = { | export type FieldType<T> = { | ||||||
|   label: string |   label: string; | ||||||
|   data: keyof T |   data: keyof T; | ||||||
|   render?: (value: any) => React.ReactNode |   render?: (value: any) => React.ReactNode; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const stationFields: Array<FieldType<StationItem>> = [ | export const stationFields: Array<FieldType<StationItem>> = [ | ||||||
|   // {label: 'ID', data: 'id'}, |   // {label: 'ID', data: 'id'}, | ||||||
|   {label: 'Название', data: 'name'}, |   { label: "Название", data: "name" }, | ||||||
|   {label: 'Системное название', data: 'system_name'}, |   { label: "Системное название", data: "system_name" }, | ||||||
|  |   // { label: "Направление", data: "direction" }, | ||||||
|  |   { label: "Адрес", data: "address" }, | ||||||
|   // {label: 'Широта', data: 'latitude'}, |   // {label: 'Широта', data: 'latitude'}, | ||||||
|   // {label: 'Долгота', data: 'longitude'}, |   // {label: 'Долгота', data: 'longitude'}, | ||||||
|   {label: 'Описание', data: 'description'}, |   { label: "Описание", data: "description" }, | ||||||
| ] | ]; | ||||||
|  |  | ||||||
| export const sightFields: Array<FieldType<SightItem>> = [ | export const sightFields: Array<FieldType<SightItem>> = [ | ||||||
|   // {label: 'ID', data: 'id'}, |   // {label: 'ID', data: 'id'}, | ||||||
|   {label: 'Название', data: 'name'}, |   { label: "Название", data: "name" }, | ||||||
|   // {label: 'Широта', data: 'latitude'}, |   // {label: 'Широта', data: 'latitude'}, | ||||||
|   // {label: 'Долгота', data: 'longitude'}, |   // {label: 'Долгота', data: 'longitude'}, | ||||||
|   // {label: 'ID города', data: 'city_id'}, |   // {label: 'ID города', data: 'city_id'}, | ||||||
|   {label: 'Город', data: 'city'}, |   { label: "Город", data: "city" }, | ||||||
| ] | ]; | ||||||
|   | |||||||
| @@ -1,48 +1,57 @@ | |||||||
| import {Autocomplete, Box, TextField} from '@mui/material' | import { Autocomplete, Box, TextField } from "@mui/material"; | ||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' | import { Edit, useAutocomplete } from "@refinedev/mui"; | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import { useForm } from "@refinedev/react-hook-form"; | ||||||
| import {Controller} from 'react-hook-form' | import { Controller } from "react-hook-form"; | ||||||
|  |  | ||||||
| import {VEHICLE_TYPES} from '../../lib/constants' | import { VEHICLE_TYPES } from "../../lib/constants"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore, META_LANGUAGE } from "@stores"; | ||||||
|  |  | ||||||
| type VehicleFormValues = { | type VehicleFormValues = { | ||||||
|   tail_number: number |   tail_number: number; | ||||||
|   type: number |   type: number; | ||||||
|   city_id: number |   city_id: number; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const VehicleEdit = () => { | export const VehicleEdit = observer(() => { | ||||||
|  |   const { language } = languageStore; | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|   } = useForm<VehicleFormValues>({}) |   } = useForm<VehicleFormValues>({ | ||||||
|  |     refineCoreProps: META_LANGUAGE(language) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ |   const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({ | ||||||
|     resource: 'carrier', |     resource: "carrier", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'short_name', |         field: "short_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('tail_number', { |           {...register("tail_number", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|             valueAsNumber: true, |             valueAsNumber: true, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.tail_number} |           error={!!(errors as any)?.tail_number} | ||||||
|           helperText={(errors as any)?.tail_number?.message} |           helperText={(errors as any)?.tail_number?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label="Бортовой номер *" |           label="Бортовой номер *" | ||||||
|           name="tail_number" |           name="tail_number" | ||||||
| @@ -52,23 +61,36 @@ export const VehicleEdit = () => { | |||||||
|           control={control} |           control={control} | ||||||
|           name="type" |           name="type" | ||||||
|           rules={{ |           rules={{ | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           }} |           }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               options={VEHICLE_TYPES} |               options={VEHICLE_TYPES} | ||||||
|               value={VEHICLE_TYPES.find((option) => option.value === field.value) || null} |               value={ | ||||||
|  |                 VEHICLE_TYPES.find((option) => option.value === field.value) || | ||||||
|  |                 null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.value || null) |                 field.onChange(value?.value || null); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.label : '' |                 return item ? item.label : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.value === value?.value |                 return option.value === value?.value; | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите тип" margin="normal" variant="outlined" error={!!errors.type} helperText={(errors as any)?.type?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите тип" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.type} | ||||||
|  |                   helperText={(errors as any)?.type?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
| @@ -76,29 +98,47 @@ export const VehicleEdit = () => { | |||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="carrier_id" |           name="carrier_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...carrierAutocompleteProps} |               {...carrierAutocompleteProps} | ||||||
|               value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 carrierAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.short_name : '' |                 return item ? item.short_name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.short_name | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите перевозчика" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.city_id} | ||||||
|  |                   helperText={(errors as any)?.city_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
| @@ -1,91 +1,179 @@ | |||||||
| import {type GridColDef} from '@mui/x-data-grid' | import { type GridColDef } from "@mui/x-data-grid"; | ||||||
| import {CustomDataGrid} from '../../components/CustomDataGrid' | import { CustomDataGrid } from "../../components/CustomDataGrid"; | ||||||
| import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' | import { | ||||||
| import React from 'react' |   DeleteButton, | ||||||
| import {VEHICLE_TYPES} from '../../lib/constants' |   EditButton, | ||||||
|  |   List, | ||||||
|  |   ShowButton, | ||||||
|  |   useDataGrid, | ||||||
|  | } from "@refinedev/mui"; | ||||||
|  | import React, { useEffect, useState } from "react"; | ||||||
|  | import { VEHICLE_TYPES } from "../../lib/constants"; | ||||||
|  |  | ||||||
| import {localeText} from '../../locales/ru/localeText' | import { localeText } from "../../locales/ru/localeText"; | ||||||
|  | import { observer } from "mobx-react-lite"; | ||||||
|  | import { languageStore } from "../../store/LanguageStore"; | ||||||
|  | import { axiosInstance } from "@providers"; | ||||||
|  |  | ||||||
| export const VehicleList = () => { | export const VehicleList = observer(() => { | ||||||
|   const {dataGridProps} = useDataGrid({}) |   const [carriers, setCarriers] = useState<any[]>([]); | ||||||
|  |  | ||||||
|  |   const [cities, setCities] = useState<any[]>([]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     axiosInstance | ||||||
|  |       .get("/carrier") | ||||||
|  |       .then((res) => { | ||||||
|  |         setCarriers(res.data); | ||||||
|  |       }) | ||||||
|  |       .catch((err) => { | ||||||
|  |         console.log(err); | ||||||
|  |       }); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     axiosInstance | ||||||
|  |       .get("/city") | ||||||
|  |       .then((res) => { | ||||||
|  |         setCities(res.data); | ||||||
|  |       }) | ||||||
|  |       .catch((err) => { | ||||||
|  |         console.log(err); | ||||||
|  |       }); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const { language } = languageStore; | ||||||
|  |  | ||||||
|  |   const { dataGridProps } = useDataGrid({ | ||||||
|  |     resource: "vehicle", | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   const columns = React.useMemo<GridColDef[]>( |   const columns = React.useMemo<GridColDef[]>( | ||||||
|     () => [ |     () => [ | ||||||
|       { |       { | ||||||
|         field: 'id', |         field: "id", | ||||||
|         headerName: 'ID', |         headerName: "ID", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 70, |         minWidth: 70, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'carrier_id', |         field: "carrier_id", | ||||||
|         headerName: 'ID перевозчика', |         headerName: "ID перевозчика", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'tail_number', |         field: "tail_number", | ||||||
|         headerName: 'Бортовой номер', |         headerName: "Бортовой номер", | ||||||
|         type: 'number', |         type: "number", | ||||||
|         minWidth: 150, |         minWidth: 150, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         align: "left", | ||||||
|         headerAlign: 'left', |         headerAlign: "left", | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'type', |         field: "type", | ||||||
|         headerName: 'Тип', |         headerName: "Тип", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         minWidth: 200, |         minWidth: 200, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'left', |         flex: 1, | ||||||
|         headerAlign: 'left', |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|         renderCell: (params) => { |         renderCell: (params) => { | ||||||
|           const value = params.row.type |           const value = params.row.type; | ||||||
|           return VEHICLE_TYPES.find((type) => type.value === value)?.label || value |           return ( | ||||||
|  |             VEHICLE_TYPES.find((type) => type.value === value)?.label || value | ||||||
|  |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'city', |         field: "carrier-name", | ||||||
|         headerName: 'Город', |         headerName: "Перевозчик", | ||||||
|         type: 'string', |         type: "string", | ||||||
|         align: 'left', |         minWidth: 150, | ||||||
|         headerAlign: 'left', |         display: "flex", | ||||||
|         flex: 1, |         flex: 1, | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |         renderCell: (params) => { | ||||||
|  |           const value = params.row.carrier_id; | ||||||
|  |           return carriers.find((carrier) => carrier.id === value)?.full_name; | ||||||
|  |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         field: 'actions', |         field: "city-name", | ||||||
|         headerName: 'Действия', |         headerName: "Город", | ||||||
|  |         type: "string", | ||||||
|  |         minWidth: 150, | ||||||
|  |         flex: 1, | ||||||
|  |         display: "flex", | ||||||
|  |         align: "left", | ||||||
|  |         headerAlign: "left", | ||||||
|  |         renderCell: (params) => { | ||||||
|  |           const value = params.row.carrier_id; | ||||||
|  |           return cities.find( | ||||||
|  |             (city) => | ||||||
|  |               city.id === | ||||||
|  |               carriers.find((carrier) => carrier.id === value)?.city_id | ||||||
|  |           )?.name; | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |       // { | ||||||
|  |       //   field: "city", | ||||||
|  |       //   headerName: "Город", | ||||||
|  |       //   type: "string", | ||||||
|  |       //   align: "left", | ||||||
|  |       //   headerAlign: "left", | ||||||
|  |       //   flex: 1, | ||||||
|  |       // }, | ||||||
|  |       { | ||||||
|  |         field: "actions", | ||||||
|  |         headerName: "Действия", | ||||||
|         minWidth: 120, |         minWidth: 120, | ||||||
|         display: 'flex', |         display: "flex", | ||||||
|         align: 'right', |         align: "right", | ||||||
|         headerAlign: 'center', |         headerAlign: "center", | ||||||
|         sortable: false, |         sortable: false, | ||||||
|         filterable: false, |         filterable: false, | ||||||
|         disableColumnMenu: true, |         disableColumnMenu: true, | ||||||
|         renderCell: function render({row}) { |         renderCell: function render({ row }) { | ||||||
|           return ( |           return ( | ||||||
|             <> |             <> | ||||||
|               <EditButton hideText recordItemId={row.id} /> |               <EditButton hideText recordItemId={row.id} /> | ||||||
|               <ShowButton hideText recordItemId={row.id} /> |               {/* <ShowButton hideText recordItemId={row.id} /> */} | ||||||
|               <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} /> |               <DeleteButton | ||||||
|  |                 hideText | ||||||
|  |                 confirmTitle="Вы уверены?" | ||||||
|  |                 recordItemId={row.id} | ||||||
|  |               /> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     [], |     [carriers, cities] | ||||||
|   ) |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <List> |     <List> | ||||||
|       <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} /> |       <CustomDataGrid | ||||||
|  |         {...dataGridProps} | ||||||
|  |         columns={columns} | ||||||
|  |         localeText={localeText} | ||||||
|  |         getRowId={(row: any) => row.id} | ||||||
|  |       /> | ||||||
|     </List> |     </List> | ||||||
|   ) |   ); | ||||||
| } | }); | ||||||
|   | |||||||
							
								
								
									
										182
									
								
								src/providers/authProvider.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								src/providers/authProvider.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | |||||||
|  | import type { AuthProvider } from "@refinedev/core"; | ||||||
|  | import axios, { AxiosError } from "axios"; | ||||||
|  |  | ||||||
|  | import { jwtDecode } from "jwt-decode"; | ||||||
|  |  | ||||||
|  | export const TOKEN_KEY = "refine-auth"; | ||||||
|  |  | ||||||
|  | interface AuthResponse { | ||||||
|  |   token: string; | ||||||
|  |   user: { | ||||||
|  |     id: number; | ||||||
|  |     name: string; | ||||||
|  |     email: string; | ||||||
|  |     is_admin: boolean; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface ErrorResponse { | ||||||
|  |   message: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class AuthError extends Error { | ||||||
|  |   constructor(message: string) { | ||||||
|  |     super(message); | ||||||
|  |     this.name = "AuthError"; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface JWTPayload { | ||||||
|  |   user_id: number; | ||||||
|  |   email: string; | ||||||
|  |   is_admin: boolean; | ||||||
|  |   exp: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const authProvider: AuthProvider = { | ||||||
|  |   login: async ({ email, password }) => { | ||||||
|  |     try { | ||||||
|  |       const response = await axios.post<AuthResponse>( | ||||||
|  |         `${import.meta.env.VITE_KRBL_API}/auth/login`, | ||||||
|  |         { | ||||||
|  |           email, | ||||||
|  |           password, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       const { token, user } = response.data; | ||||||
|  |  | ||||||
|  |       if (token) { | ||||||
|  |         localStorage.setItem(TOKEN_KEY, token); | ||||||
|  |         localStorage.setItem("user", JSON.stringify(user)); | ||||||
|  |  | ||||||
|  |         return { | ||||||
|  |           success: true, | ||||||
|  |           redirectTo: "/", | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       throw new AuthError("Неверный email или пароль"); | ||||||
|  |     } catch (error) { | ||||||
|  |       const errorMessage = | ||||||
|  |         (error as AxiosError<ErrorResponse>)?.response?.data?.message || | ||||||
|  |         "Неверный email или пароль"; | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         success: false, | ||||||
|  |         error: new AuthError(errorMessage), | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   logout: async () => { | ||||||
|  |     try { | ||||||
|  |       await axios.post( | ||||||
|  |         `${import.meta.env.VITE_KRBL_API}/auth/logout`, | ||||||
|  |         {}, | ||||||
|  |         { | ||||||
|  |           headers: { | ||||||
|  |             Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, | ||||||
|  |           }, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error("Ошибка при выходе:", error); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     localStorage.removeItem(TOKEN_KEY); | ||||||
|  |     localStorage.removeItem("user"); | ||||||
|  |     return { | ||||||
|  |       success: true, | ||||||
|  |       redirectTo: "/login", | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   check: async () => { | ||||||
|  |     const token = localStorage.getItem(TOKEN_KEY); | ||||||
|  |     if (!token) { | ||||||
|  |       return { | ||||||
|  |         authenticated: false, | ||||||
|  |         redirectTo: "/login", | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       const response = await axios.get( | ||||||
|  |         `${import.meta.env.VITE_KRBL_API}/auth/me`, | ||||||
|  |         { | ||||||
|  |           headers: { | ||||||
|  |             Authorization: `Bearer ${token}`, | ||||||
|  |           }, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (response.status === 200) { | ||||||
|  |         // Обновляем информацию о пользователе | ||||||
|  |         localStorage.setItem("user", JSON.stringify(response.data)); | ||||||
|  |         return { | ||||||
|  |           authenticated: true, | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       localStorage.removeItem(TOKEN_KEY); | ||||||
|  |       localStorage.removeItem("user"); | ||||||
|  |       return { | ||||||
|  |         authenticated: false, | ||||||
|  |         redirectTo: "/login", | ||||||
|  |         error: new AuthError("Сессия истекла, пожалуйста, войдите снова"), | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       authenticated: false, | ||||||
|  |       redirectTo: "/login", | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   getPermissions: async () => { | ||||||
|  |     const token = localStorage.getItem(TOKEN_KEY); | ||||||
|  |     if (!token) return null; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       const decoded = jwtDecode<JWTPayload>(token); | ||||||
|  |       if (decoded.is_admin) { | ||||||
|  |         document.body.classList.add("is-admin"); | ||||||
|  |       } else { | ||||||
|  |         document.body.classList.remove("is-admin"); | ||||||
|  |       } | ||||||
|  |       return decoded.is_admin ? ["admin"] : ["user"]; | ||||||
|  |     } catch { | ||||||
|  |       document.body.classList.remove("is-admin"); | ||||||
|  |       return ["user"]; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   getIdentity: async () => { | ||||||
|  |     const token = localStorage.getItem(TOKEN_KEY); | ||||||
|  |     const user = localStorage.getItem("user"); | ||||||
|  |  | ||||||
|  |     if (!token || !user) return null; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       const decoded = jwtDecode<JWTPayload>(token); | ||||||
|  |       const userData = JSON.parse(user); | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         ...userData, | ||||||
|  |         is_admin: decoded.is_admin, // всегда используем значение из токена | ||||||
|  |       }; | ||||||
|  |     } catch { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   onError: async (error) => { | ||||||
|  |     const status = (error as AxiosError)?.response?.status; | ||||||
|  |     if (status === 401 || status === 403) { | ||||||
|  |       localStorage.removeItem(TOKEN_KEY); | ||||||
|  |       localStorage.removeItem("user"); | ||||||
|  |       return { | ||||||
|  |         logout: true, | ||||||
|  |         redirectTo: "/login", | ||||||
|  |         error: new AuthError("Сессия истекла, пожалуйста, войдите снова"), | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |     return { error }; | ||||||
|  |   }, | ||||||
|  | }; | ||||||
| @@ -1,25 +1,43 @@ | |||||||
| import dataProvider from '@refinedev/simple-rest' | import dataProvider from "@refinedev/simple-rest"; | ||||||
| import axios from 'axios' |  | ||||||
| import {BACKEND_URL} from '../lib/constants' |  | ||||||
| import {TOKEN_KEY} from '../authProvider' |  | ||||||
| import Cookies from 'js-cookie' |  | ||||||
|  |  | ||||||
| export const axiosInstance = axios.create() | import { TOKEN_KEY } from "@providers"; | ||||||
|  |  | ||||||
|  | import axios from "axios"; | ||||||
|  |  | ||||||
|  | export const axiosInstance = axios.create({ | ||||||
|  |   baseURL: import.meta.env.VITE_KRBL_API, | ||||||
|  | }); | ||||||
|  |  | ||||||
| axiosInstance.interceptors.request.use((config) => { | axiosInstance.interceptors.request.use((config) => { | ||||||
|   // Добавляем токен авторизации |   // Добавляем токен авторизации | ||||||
|   const token = localStorage.getItem(TOKEN_KEY) |   const token = localStorage.getItem(TOKEN_KEY); | ||||||
|   if (token) { |   if (token) { | ||||||
|     config.headers.Authorization = `Bearer ${token}` |     config.headers.Authorization = `Bearer ${token}`; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Добавляем язык в кастомный заголовок |   // Добавляем язык в кастомный заголовок | ||||||
|   const lang = Cookies.get('lang') || 'ru' |  | ||||||
|   config.headers['X-Language'] = lang // или 'Accept-Language' |  | ||||||
|  |  | ||||||
|   // console.log('Request headers:', config.headers) |   config.headers["X-Language"] = config.headers["Accept-Language"]; | ||||||
|  |  | ||||||
|   return config |   return config; | ||||||
| }) | }); | ||||||
|  |  | ||||||
| export const customDataProvider = dataProvider(BACKEND_URL, axiosInstance) | export const axiosInstanceForGet = (language: string) => { | ||||||
|  |   const axiosInstance = axios.create({ | ||||||
|  |     baseURL: import.meta.env.VITE_KRBL_API, | ||||||
|  |   }); | ||||||
|  |   axiosInstance.interceptors.request.use((config) => { | ||||||
|  |     const token = localStorage.getItem(TOKEN_KEY); | ||||||
|  |     if (token) { | ||||||
|  |       config.headers.Authorization = `Bearer ${token}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     config.headers["X-Language"] = language; | ||||||
|  |     return config; | ||||||
|  |   }); | ||||||
|  |   return axiosInstance; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const apiUrl = import.meta.env.VITE_KRBL_API; | ||||||
|  |  | ||||||
|  | export const customDataProvider = dataProvider(apiUrl, axiosInstance); | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import i18n from 'i18next' | |||||||
| import {initReactI18next} from 'react-i18next' | import {initReactI18next} from 'react-i18next' | ||||||
| import {I18nProvider} from '@refinedev/core' | import {I18nProvider} from '@refinedev/core' | ||||||
| 
 | 
 | ||||||
| import translationRU from './locales/ru/translation.json' | import translationRU from '../locales/ru/translation.json' | ||||||
| 
 | 
 | ||||||
| i18n.use(initReactI18next).init({ | i18n.use(initReactI18next).init({ | ||||||
|   resources: { |   resources: { | ||||||
							
								
								
									
										3
									
								
								src/providers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/providers/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export * from './data' | ||||||
|  | export * from './authProvider' | ||||||
|  | export * from './i18nProvider' | ||||||
							
								
								
									
										20
									
								
								src/store/ArticleStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/store/ArticleStore.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | import { makeAutoObservable } from "mobx"; | ||||||
|  |  | ||||||
|  | class ArticleStore { | ||||||
|  |   articleModalOpen: boolean = false; | ||||||
|  |   selectedArticleId: number | null = null; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     makeAutoObservable(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setArticleIdAction = (id: number) => { | ||||||
|  |     this.selectedArticleId = id; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   setArticleModalOpenAction = (open: boolean) => { | ||||||
|  |     this.articleModalOpen = open; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const articleStore = new ArticleStore(); | ||||||
							
								
								
									
										26
									
								
								src/store/CityStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/store/CityStore.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | import { makeAutoObservable } from "mobx"; | ||||||
|  |  | ||||||
|  | class CityStore { | ||||||
|  |   city_id: string = "0"; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     makeAutoObservable(this); | ||||||
|  |     this.initialize(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   initialize() { | ||||||
|  |     const id = localStorage.getItem("city_id"); | ||||||
|  |     if (id) { | ||||||
|  |       this.city_id = id; | ||||||
|  |     } else { | ||||||
|  |       this.city_id = "0"; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setCityIdAction = (city_id: string) => { | ||||||
|  |     this.city_id = city_id; | ||||||
|  |     localStorage.setItem("city_id", city_id); | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const cityStore = new CityStore(); | ||||||
							
								
								
									
										34
									
								
								src/store/LanguageStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/store/LanguageStore.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | import { makeAutoObservable } from "mobx"; | ||||||
|  |  | ||||||
|  | export type Languages = "en" | "ru" | "zh"; | ||||||
|  | class LanguageStore { | ||||||
|  |   language: Languages = "ru"; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     makeAutoObservable(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setLanguageAction = (language: Languages) => { | ||||||
|  |     this.language = language; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const languageStore = new LanguageStore(); | ||||||
|  |  | ||||||
|  | export const META_LANGUAGE = (language: Languages) => { | ||||||
|  |   return { | ||||||
|  |     meta: { | ||||||
|  |       headers: { | ||||||
|  |         "Accept-Language": language, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const EVERY_LANGUAGE = (data: any) => { | ||||||
|  |   return { | ||||||
|  |     "en": data, | ||||||
|  |     "ru": data, | ||||||
|  |     "zh": data, | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										25
									
								
								src/store/StationStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/store/StationStore.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | import { makeAutoObservable } from "mobx"; | ||||||
|  |  | ||||||
|  | class StationStore { | ||||||
|  |   stationModalOpen: boolean = false; | ||||||
|  |   selectedStationId: number | null = null; | ||||||
|  |   selectedRouteId: number | null = null; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     makeAutoObservable(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   setStationIdAction = (id: number) => { | ||||||
|  |     this.selectedStationId = id; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   setRouteIdAction = (id: number) => { | ||||||
|  |     this.selectedRouteId = id; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   setStationModalOpenAction = (open: boolean) => { | ||||||
|  |     this.stationModalOpen = open; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const stationStore = new StationStore(); | ||||||
							
								
								
									
										4
									
								
								src/store/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/store/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | export * from './ArticleStore'; | ||||||
|  | export * from './CityStore'; | ||||||
|  | export * from './LanguageStore'; | ||||||
|  | export * from './StationStore'; | ||||||
							
								
								
									
										8
									
								
								svg.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								svg.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | declare module "*.svg" { | ||||||
|  |   import * as React from "react"; | ||||||
|  |   export const ReactComponent: React.FunctionComponent< | ||||||
|  |     React.SVGProps<SVGSVGElement> | ||||||
|  |   >; | ||||||
|  |   const src: string; | ||||||
|  |   export default src; | ||||||
|  | } | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user