Compare commits
55 Commits
d8302e05b4
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e11ad5ca | |||
| 7e068e49f5 | |||
| 79539d0583 | |||
| c5c5f835bc | |||
| 5481d264e0 | |||
| d6772b1e3a | |||
| 11133b6839 | |||
| aaeaed3fa5 | |||
| 95fe297aae | |||
| 04a9ac452e | |||
| 85c71563c1 | |||
| 6f32c6e671 | |||
| 0a6192c7da | |||
| b1ba3b4cd5 | |||
| 1917b2cf5a | |||
| 5298fb9f60 | |||
| c95a6517e9 | |||
| 79f523e9cb | |||
| 90f3d66b22 | |||
| 2b48ade2f1 | |||
| b0fdf03cc6 | |||
|
|
349c7009c6 | ||
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 | |||
| a357994025 | |||
| 7382a85082 | |||
| db64beb3ee | |||
|
|
1abd6b30a4 | ||
|
|
b25df42960 | ||
|
34ba3c1db0
|
|||
| 4f038551a2 | |||
| 470a58a3fa | |||
| 89d7fc2748 | |||
| 97f95fc394 | |||
| bf117ef048 | |||
| ced3067915 | |||
| a908c63771 | |||
| 06eafee3f4 | |||
| 717031cd7a | |||
|
2d4a1e169b
|
|||
| e2547cb571 | |||
| 78800ee2ae | |||
| d415441af8 | |||
| 32a7cb44d1 | |||
| 481385c2f4 | |||
| 2117a6836e | |||
| f49caf3ec8 | |||
| 300ff262ce | |||
| 27cb644242 |
6
.env
@@ -1,2 +1,4 @@
|
|||||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
VITE_API_URL='https://wn.krbl.ru'
|
||||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
VITE_REACT_APP ='https://wn.krbl.ru'
|
||||||
|
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||||
|
VITE_NEED_AUTH='true'
|
||||||
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Stage 1: Build the application
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package.json and yarn.lock
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Stage 2: Serve the application with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy the built application from the build stage
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration (optional, can be added later if needed)
|
||||||
|
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start Nginx server
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Variables
|
||||||
|
IMAGE_NAME = white-nights-admin-panel
|
||||||
|
IMAGE_TAG = latest
|
||||||
|
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
|
||||||
|
ARCHIVE_NAME = white-nights-admin-panel-image.zip
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "Available commands:"
|
||||||
|
@echo " make build-image - Build Docker image"
|
||||||
|
@echo " make export-image - Build Docker image and export it to a zip archive"
|
||||||
|
@echo " make clean - Remove Docker image and zip archive"
|
||||||
|
@echo " make help - Show this help message"
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
.PHONY: build-image
|
||||||
|
build-image:
|
||||||
|
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
|
||||||
|
docker build -t $(FULL_IMAGE_NAME) .
|
||||||
|
|
||||||
|
# Export Docker image to zip archive
|
||||||
|
.PHONY: export-image
|
||||||
|
export-image: build-image
|
||||||
|
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
|
||||||
|
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
|
||||||
|
@echo "Image exported successfully to $(ARCHIVE_NAME)"
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
@echo "Removing Docker image and zip archive"
|
||||||
|
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
|
||||||
|
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
|
||||||
|
@echo "Clean up completed"
|
||||||
|
|
||||||
|
# Default target when no arguments provided
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/favicon_ship.png" />
|
<link rel="icon" type="image/svg" href="/favicon_ship.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Белые ночи</title>
|
<title>Белые ночи</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
3488
package-lock.json
generated
15
package.json
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"easymde": "^2.20.0",
|
"easymde": "^2.20.0",
|
||||||
|
"i18n-iso-countries": "^7.14.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"mobx": "^6.13.7",
|
"mobx": "^6.13.7",
|
||||||
"mobx-react-lite": "^4.1.0",
|
"mobx-react-lite": "^4.1.0",
|
||||||
@@ -30,17 +32,17 @@
|
|||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pixi.js": "^8.10.1",
|
"pixi.js": "^8.10.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-colorful": "^5.6.1",
|
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-dropzone": "^14.3.8",
|
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-photo-sphere-viewer": "^6.2.3",
|
"react-photo-sphere-viewer": "^6.2.3",
|
||||||
|
"react-router": "^7.9.4",
|
||||||
"react-router-dom": "^7.6.1",
|
"react-router-dom": "^7.6.1",
|
||||||
"react-simplemde-editor": "^5.2.0",
|
"react-simplemde-editor": "^5.2.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
"three": "^0.177.0"
|
"three": "^0.177.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
@@ -53,8 +55,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5",
|
||||||
}
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 2.3 KiB |
3
public/favicon_ship.svg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
21
public/sight_icon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg width="53" height="49" viewBox="0 0 53 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 19.9296H52.7174V15.9968L26.3662 0L0 15.9968V19.9296ZM26.3659 3.75713L4.75331 16.8616H47.9636L26.3659 3.75713Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M52.7174 45.4072H0V48.3587H52.7174V45.4072Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M50.0742 41.4756H2.64355V44.427H50.0742V41.4756Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M9.46312 21.6035H5.49805V39.0244H9.46312V21.6035Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M11.4448 39.4316H3.51465V40.5827H11.4448V39.4316Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M4.40104 20.6592C4.10066 20.6592 3.86035 20.8953 3.86035 21.1904C3.86035 21.4856 4.10066 21.7217 4.40104 21.7217C4.70143 21.7217 4.94173 21.4856 4.94173 21.1904H10.0182C10.0182 21.4856 10.2585 21.7217 10.5589 21.7217C10.8593 21.7217 11.0996 21.4856 11.0996 21.1904C11.0996 20.8953 10.8593 20.6592 10.5589 20.6592H4.40104Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M22.0979 21.6035H18.1328V39.0244H22.0979V21.6035Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M24.0815 39.4316H16.1514V40.5827H24.0815V39.4316Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M17.0358 20.6592C16.7354 20.6592 16.4951 20.8953 16.4951 21.1904C16.4951 21.4856 16.7354 21.7217 17.0358 21.7217C17.3362 21.7217 17.5765 21.4856 17.5765 21.1904H22.653C22.653 21.4856 22.8933 21.7217 23.1937 21.7217C23.4941 21.7217 23.7344 21.4856 23.7344 21.1904C23.7344 20.8953 23.4941 20.6592 23.1937 20.6592H17.0358Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M34.7414 21.6035H30.7764V39.0244H34.7414V21.6035Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M36.7231 39.4316H28.793V40.5827H36.7231V39.4316Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M29.6794 20.6592C29.379 20.6592 29.1387 20.8953 29.1387 21.1904C29.1387 21.4856 29.379 21.7217 29.6794 21.7217C29.9797 21.7217 30.2201 21.4856 30.2201 21.1904H35.2965C35.2965 21.4856 35.5369 21.7217 35.8372 21.7217C36.1376 21.7217 36.3779 21.4856 36.3779 21.1904C36.3779 20.8953 36.1376 20.6592 35.8372 20.6592H29.6794Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M47.3762 21.6045H43.4111V39.0254H47.3762V21.6045Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M49.3598 39.4316H41.4297V40.5827H49.3598V39.4316Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M42.3141 20.6592C42.0137 20.6592 41.7734 20.8953 41.7734 21.1904C41.7734 21.4856 42.0137 21.7217 42.3141 21.7217C42.6145 21.7217 42.8548 21.4856 42.8548 21.1904H47.9313C47.9313 21.4856 48.1716 21.7217 48.472 21.7217C48.7724 21.7217 49.0127 21.4856 49.0127 21.1904C49.0127 20.8953 48.7724 20.6592 48.472 20.6592H42.3141Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M26.8478 9.42308C26.8478 9.18696 26.6151 8.99512 26.3297 8.99512C26.0443 8.99512 25.8115 9.18696 25.8115 9.42308V9.76249C25.8115 10.0429 26.0443 10.2716 26.3297 10.2716C26.6151 10.2716 26.8478 10.0429 26.8478 9.76249V9.42308Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M19.5098 11.6514C19.2695 11.6514 19.0742 11.8801 19.0742 12.1679C19.0742 12.4483 19.2695 12.6844 19.5098 12.6844H20.4109C20.6963 12.6844 20.9366 12.4556 20.9366 12.1679C20.9366 11.8875 20.7038 11.6514 20.4109 11.6514H19.5098Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M32.2022 11.6514C31.9619 11.6514 31.7666 11.8801 31.7666 12.1679C31.7666 12.4483 31.9619 12.6844 32.2022 12.6844H33.1033C33.3887 12.6844 33.629 12.4556 33.629 12.1679C33.629 11.8875 33.3962 11.6514 33.1033 11.6514H32.2022Z" fill="#A6A6A6"/>
|
||||||
|
<path d="M27.6188 11.1644L26.973 10.7586C26.973 10.7586 26.8979 10.7217 26.8528 10.7217H25.8015C25.7564 10.7217 25.7189 10.7364 25.6813 10.7586L25.0355 11.1644C24.9679 11.2087 24.9304 11.2751 24.9304 11.3489V11.8211C24.9304 11.8654 24.9454 11.9096 24.9679 11.9465L25.5311 12.7656C25.5762 12.832 25.5837 12.9057 25.5537 12.9795L24.9454 14.3372C24.9454 14.3372 24.9229 14.3962 24.9229 14.4257V15.776C24.9229 15.9015 25.0205 15.9974 25.1481 15.9974H27.4911C27.6188 15.9974 27.7164 15.9015 27.7164 15.776V14.4257C27.7164 14.4257 27.7164 14.3667 27.6939 14.3372L27.0856 12.9795C27.0556 12.9131 27.0631 12.832 27.1081 12.7656L27.6714 11.9465C27.6714 11.9465 27.7089 11.8654 27.7089 11.8211V11.3489C27.7089 11.2751 27.6714 11.2013 27.6038 11.1644H27.6188Z" fill="#A6A6A6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { Component, ReactNode } from "react";
|
||||||
|
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||||
|
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleGoHome = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "background.default",
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={64} color="#f44336" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Упс! Что-то пошло не так
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||||
|
перезагрузить страницу или вернуться на главную.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{this.state.error?.message && (
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
mb: 3,
|
||||||
|
backgroundColor: "error.light",
|
||||||
|
color: "error.contrastText",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||||
|
>
|
||||||
|
Информация об ошибке:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error.message}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
justifyContent: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Home size={16} />}
|
||||||
|
onClick={this.handleGoHome}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
На главную
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<RefreshCw size={16} />}
|
||||||
|
onClick={this.handleReset}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Перезагрузить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import { Router } from "./router";
|
|||||||
import { CustomTheme } from "@shared";
|
import { CustomTheme } from "@shared";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||||
|
|
||||||
export const App: React.FC = () => (
|
export const App: React.FC = () => (
|
||||||
<ThemeProvider theme={CustomTheme.Light}>
|
<GlobalErrorBoundary>
|
||||||
<ToastContainer />
|
<ThemeProvider theme={CustomTheme.Light}>
|
||||||
<Router />
|
<ToastContainer />
|
||||||
</ThemeProvider>
|
<Router />
|
||||||
|
</ThemeProvider>
|
||||||
|
</GlobalErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ import {
|
|||||||
SnapshotListPage,
|
SnapshotListPage,
|
||||||
CarrierListPage,
|
CarrierListPage,
|
||||||
StationListPage,
|
StationListPage,
|
||||||
VehicleListPage,
|
|
||||||
ArticleListPage,
|
ArticleListPage,
|
||||||
CityPreviewPage,
|
|
||||||
CountryPreviewPage,
|
|
||||||
VehiclePreviewPage,
|
|
||||||
CarrierPreviewPage,
|
|
||||||
SnapshotCreatePage,
|
SnapshotCreatePage,
|
||||||
CountryCreatePage,
|
CountryCreatePage,
|
||||||
CityCreatePage,
|
CityCreatePage,
|
||||||
@@ -31,7 +26,6 @@ import {
|
|||||||
CityEditPage,
|
CityEditPage,
|
||||||
UserCreatePage,
|
UserCreatePage,
|
||||||
UserEditPage,
|
UserEditPage,
|
||||||
VehicleEditPage,
|
|
||||||
CarrierEditPage,
|
CarrierEditPage,
|
||||||
StationCreatePage,
|
StationCreatePage,
|
||||||
StationPreviewPage,
|
StationPreviewPage,
|
||||||
@@ -39,6 +33,8 @@ import {
|
|||||||
RouteCreatePage,
|
RouteCreatePage,
|
||||||
RoutePreview,
|
RoutePreview,
|
||||||
RouteEditPage,
|
RouteEditPage,
|
||||||
|
ArticlePreviewPage,
|
||||||
|
CountryAddPage,
|
||||||
} from "@pages";
|
} from "@pages";
|
||||||
import { authStore, createSightStore, editSightStore } from "@shared";
|
import { authStore, createSightStore, editSightStore } from "@shared";
|
||||||
import { Layout } from "@widgets";
|
import { Layout } from "@widgets";
|
||||||
@@ -55,25 +51,31 @@ import {
|
|||||||
|
|
||||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated } = authStore;
|
const { isAuthenticated } = authStore;
|
||||||
if (isAuthenticated) {
|
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||||
return <Navigate to="/sight" replace />;
|
|
||||||
|
if (isAuthenticated || !need_auth) {
|
||||||
|
return <Navigate to="/map" replace />;
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated } = authStore;
|
const { isAuthenticated } = authStore;
|
||||||
|
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!isAuthenticated) {
|
|
||||||
|
if (!isAuthenticated && need_auth) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location.pathname === "/") {
|
if (location.pathname === "/") {
|
||||||
return <Navigate to="/sight" replace />;
|
return <Navigate to="/map" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Чтобы очистка сторов происходила при смене локации
|
|
||||||
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -114,64 +116,45 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <MainPage /> },
|
{ index: true, element: <MainPage /> },
|
||||||
|
|
||||||
// Sight
|
|
||||||
{ path: "sight", element: <SightListPage /> },
|
{ path: "sight", element: <SightListPage /> },
|
||||||
{ path: "sight/create", element: <CreateSightPage /> },
|
{ path: "sight/create", element: <CreateSightPage /> },
|
||||||
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
||||||
|
|
||||||
// Device
|
|
||||||
{ path: "devices", element: <DevicesPage /> },
|
{ path: "devices", element: <DevicesPage /> },
|
||||||
|
|
||||||
// Map
|
|
||||||
{ path: "map", element: <MapPage /> },
|
{ path: "map", element: <MapPage /> },
|
||||||
|
|
||||||
// Media
|
|
||||||
{ path: "media", element: <MediaListPage /> },
|
{ path: "media", element: <MediaListPage /> },
|
||||||
{ path: "media/:id", element: <MediaPreviewPage /> },
|
{ path: "media/:id", element: <MediaPreviewPage /> },
|
||||||
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
||||||
|
|
||||||
// Country
|
|
||||||
{ path: "country", element: <CountryListPage /> },
|
{ path: "country", element: <CountryListPage /> },
|
||||||
{ path: "country/create", element: <CountryCreatePage /> },
|
{ path: "country/create", element: <CountryCreatePage /> },
|
||||||
{ path: "country/:id", element: <CountryPreviewPage /> },
|
{ path: "country/add", element: <CountryAddPage /> },
|
||||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||||
// City
|
|
||||||
{ path: "city", element: <CityListPage /> },
|
{ path: "city", element: <CityListPage /> },
|
||||||
{ path: "city/create", element: <CityCreatePage /> },
|
{ path: "city/create", element: <CityCreatePage /> },
|
||||||
{ path: "city/:id", element: <CityPreviewPage /> },
|
|
||||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||||
// Route
|
|
||||||
{ path: "route", element: <RouteListPage /> },
|
{ path: "route", element: <RouteListPage /> },
|
||||||
{ path: "route/create", element: <RouteCreatePage /> },
|
{ path: "route/create", element: <RouteCreatePage /> },
|
||||||
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
||||||
|
|
||||||
// User
|
|
||||||
{ path: "user", element: <UserListPage /> },
|
{ path: "user", element: <UserListPage /> },
|
||||||
{ path: "user/create", element: <UserCreatePage /> },
|
{ path: "user/create", element: <UserCreatePage /> },
|
||||||
{ path: "user/:id/edit", element: <UserEditPage /> },
|
{ path: "user/:id/edit", element: <UserEditPage /> },
|
||||||
// Snapshot
|
|
||||||
{ path: "snapshot", element: <SnapshotListPage /> },
|
{ path: "snapshot", element: <SnapshotListPage /> },
|
||||||
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
||||||
|
|
||||||
// Carrier
|
|
||||||
{ path: "carrier", element: <CarrierListPage /> },
|
{ path: "carrier", element: <CarrierListPage /> },
|
||||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||||
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
|
|
||||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||||
// Station
|
|
||||||
{ path: "station", element: <StationListPage /> },
|
{ path: "station", element: <StationListPage /> },
|
||||||
{ path: "station/create", element: <StationCreatePage /> },
|
{ path: "station/create", element: <StationCreatePage /> },
|
||||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||||
// Vehicle
|
|
||||||
{ path: "vehicle", element: <VehicleListPage /> },
|
|
||||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||||
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
|
||||||
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
|
||||||
// Article
|
|
||||||
{ path: "article", element: <ArticleListPage /> },
|
{ path: "article", element: <ArticleListPage /> },
|
||||||
// { path: "article/:id", element: <ArticlePreviewPage /> },
|
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||||
// { path: "media/create", element: <CreateMediaPage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -5,6 +5,7 @@ export interface NavigationItem {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
for_admin?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
nestedItems?: NavigationItem[];
|
nestedItems?: NavigationItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
|||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import type { NavigationItem } from "../model";
|
import type { NavigationItem } from "../model";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { authStore } from "@shared";
|
||||||
|
|
||||||
interface NavigationItemProps {
|
interface NavigationItemProps {
|
||||||
item: NavigationItem;
|
item: NavigationItem;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
isNested?: boolean;
|
isNested?: boolean;
|
||||||
|
onDrawerOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||||
@@ -22,15 +25,33 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
|||||||
open,
|
open,
|
||||||
onClick,
|
onClick,
|
||||||
isNested = false,
|
isNested = false,
|
||||||
|
onDrawerOpen,
|
||||||
}) => {
|
}) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
const { payload } = authStore;
|
||||||
|
|
||||||
|
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const isAdmin = payload?.is_admin || false || !need_auth;
|
||||||
|
|
||||||
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
||||||
|
|
||||||
|
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
|
||||||
|
if (nestedItem.for_admin) {
|
||||||
|
return isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
if (item.id === "all" && !open) {
|
||||||
|
onDrawerOpen?.();
|
||||||
|
}
|
||||||
if (item.nestedItems) {
|
if (item.nestedItems) {
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
} else if (onClick) {
|
} else if (onClick) {
|
||||||
@@ -58,7 +79,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
isNested && {
|
isNested && {
|
||||||
pl: 4,
|
pl: open ? 4 : 2.5,
|
||||||
},
|
},
|
||||||
isActive && {
|
isActive && {
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.08)",
|
backgroundColor: "rgba(0, 0, 0, 0.08)",
|
||||||
@@ -84,7 +105,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Icon />
|
{Icon ? <Icon /> : <Plus />}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={item.label}
|
primary={item.label}
|
||||||
@@ -102,15 +123,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{item.nestedItems &&
|
{filteredNestedItems &&
|
||||||
|
filteredNestedItems.length > 0 &&
|
||||||
open &&
|
open &&
|
||||||
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
{item.nestedItems && (
|
{filteredNestedItems && filteredNestedItems.length > 0 && (
|
||||||
<Collapse in={isExpanded && open} timeout="auto" unmountOnExit>
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
<List component="div" disablePadding>
|
<List component="div" disablePadding>
|
||||||
{item.nestedItems.map((nestedItem) => (
|
{filteredNestedItems.map((nestedItem) => (
|
||||||
<NavigationItemComponent
|
<NavigationItemComponent
|
||||||
key={nestedItem.id}
|
key={nestedItem.id}
|
||||||
item={nestedItem}
|
item={nestedItem}
|
||||||
|
|||||||
@@ -1,34 +1,62 @@
|
|||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import { NAVIGATION_ITEMS } from "@shared";
|
import { authStore, NAVIGATION_ITEMS } from "@shared";
|
||||||
import { NavigationItem, NavigationItemComponent } from "@entities";
|
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
export const NavigationList = ({ open }: { open: boolean }) => {
|
interface NavigationListProps {
|
||||||
const primaryItems = NAVIGATION_ITEMS.primary;
|
open: boolean;
|
||||||
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
onDrawerOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export const NavigationList = observer(
|
||||||
<>
|
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||||
<List>
|
const { payload } = authStore;
|
||||||
{primaryItems.map((item) => (
|
// @ts-ignore
|
||||||
<NavigationItemComponent
|
const isAdmin = Boolean(payload?.is_admin) || false;
|
||||||
key={item.id}
|
|
||||||
item={item as NavigationItem}
|
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
||||||
open={open}
|
if (item.for_admin) {
|
||||||
/>
|
return isAdmin;
|
||||||
))}
|
}
|
||||||
</List>
|
|
||||||
<Divider />
|
if (item.nestedItems && item.nestedItems.length > 0) {
|
||||||
<List>
|
return item.nestedItems.some((nestedItem) => {
|
||||||
{secondaryItems.map((item) => (
|
if (nestedItem.for_admin) {
|
||||||
<NavigationItemComponent
|
return isAdmin;
|
||||||
key={item.id}
|
}
|
||||||
item={item as NavigationItem}
|
return true;
|
||||||
open={open}
|
});
|
||||||
onClick={item.onClick ? item.onClick : undefined}
|
}
|
||||||
/>
|
|
||||||
))}
|
return true;
|
||||||
</List>
|
});
|
||||||
</>
|
|
||||||
);
|
return (
|
||||||
};
|
<>
|
||||||
|
<List>
|
||||||
|
{primaryItems.map((item) => (
|
||||||
|
<NavigationItemComponent
|
||||||
|
key={item.id}
|
||||||
|
item={item as NavigationItem}
|
||||||
|
open={open}
|
||||||
|
onDrawerOpen={onDrawerOpen}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{NAVIGATION_ITEMS.secondary.map((item) => (
|
||||||
|
<NavigationItemComponent
|
||||||
|
key={item.id}
|
||||||
|
item={item as NavigationItem}
|
||||||
|
open={open}
|
||||||
|
onClick={item.onClick ? item.onClick : undefined}
|
||||||
|
onDrawerOpen={onDrawerOpen}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/pages/Article/ArticleCreatePage/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
import { articlesStore } from "@shared";
|
||||||
|
|
||||||
|
const ArticleCreatePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { articleData } = articlesStore;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||||
|
<h1 className="text-3xl break-words">
|
||||||
|
{articleData?.ru?.heading || "Создание статьи"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArticleCreatePage;
|
||||||
49
src/pages/Article/ArticleEditPage/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
import { articlesStore, languageStore } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
const ArticleEditPage: React.FC = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const { articleData, getArticle } = articlesStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Устанавливаем русский язык при загрузке страницы
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
// Fetch data for all languages
|
||||||
|
getArticle(parseInt(id), "ru");
|
||||||
|
getArticle(parseInt(id), "en");
|
||||||
|
getArticle(parseInt(id), "zh");
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||||
|
<h1 className="text-3xl break-words">
|
||||||
|
{articleData?.ru?.heading || "Редактирование статьи"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ArticleEditPage;
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { articlesStore, languageStore } from "@shared";
|
import { articlesStore, languageStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Trash2, Eye } from "lucide-react";
|
import { Trash2, Eye, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const ArticleListPage = observer(() => {
|
export const ArticleListPage = observer(() => {
|
||||||
const { articleList, getArticleList } = articlesStore;
|
const { articleList, getArticleList, deleteArticles } = articlesStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getArticleList();
|
const fetchArticles = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getArticleList();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchArticles();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -22,11 +32,21 @@ export const ArticleListPage = observer(() => {
|
|||||||
field: "heading",
|
field: "heading",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full gap-7 items-center">
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -59,18 +79,53 @@ export const ArticleListPage = observer(() => {
|
|||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<DataGrid
|
<div className="flex justify-between items-center mb-10">
|
||||||
rows={rows}
|
<h1 className="text-2xl">Статьи</h1>
|
||||||
columns={columns}
|
</div>
|
||||||
hideFooterPagination
|
|
||||||
hideFooter
|
<div
|
||||||
/>
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids) as number[]);
|
||||||
|
}}
|
||||||
|
hideFooter
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box
|
||||||
|
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeleteModal
|
<DeleteModal
|
||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (rowId) {
|
if (rowId) {
|
||||||
|
await deleteArticles([parseInt(rowId)]);
|
||||||
getArticleList();
|
getArticleList();
|
||||||
}
|
}
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
@@ -81,6 +136,19 @@ export const ArticleListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await deleteArticles(ids);
|
||||||
|
getArticleList();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
85
src/pages/Article/ArticlePreviewPage/PreviewLeftWidget.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Paper, Box, Typography } from "@mui/material";
|
||||||
|
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
|
||||||
|
import { articlesStore, languageStore } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
export const PreviewLeftWidget = observer(() => {
|
||||||
|
const { articleMedia, articleData } = articlesStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 320,
|
||||||
|
background:
|
||||||
|
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||||
|
overflowY: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRadius: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 100,
|
||||||
|
padding: "3px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
"& img": {
|
||||||
|
borderTopLeftRadius: "10px",
|
||||||
|
borderTopRightRadius: "10px",
|
||||||
|
width: "100%",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{articleMedia && <MediaViewer media={articleMedia} fullWidth />}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background:
|
||||||
|
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||||
|
color: "white",
|
||||||
|
margin: "5px 0px 5px 0px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1,
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
wordBreak: "break-word",
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "120%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{articleData?.[language]?.heading || "Название информации"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{articleData?.[language]?.body && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
padding: 1,
|
||||||
|
maxHeight: "300px",
|
||||||
|
overflowY: "scroll",
|
||||||
|
background:
|
||||||
|
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdownComponent value={articleData?.[language]?.body} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
||||||
139
src/pages/Article/ArticlePreviewPage/PreviewRightWidget.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Paper, Box, Typography } from "@mui/material";
|
||||||
|
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
|
||||||
|
import { articlesStore, languageStore } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ImagePlus } from "lucide-react";
|
||||||
|
|
||||||
|
export const PreviewRightWidget = observer(() => {
|
||||||
|
const { articleData, articleMedia } = articlesStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
const article = articleData?.[language];
|
||||||
|
if (!article) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
className="flex-1 flex flex-col max-w-[500px]"
|
||||||
|
sx={{
|
||||||
|
borderRadius: "10px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
elevation={2}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="overflow-hidden"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
background: "#877361",
|
||||||
|
borderColor: "grey.300",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{articleMedia ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
width: "100%",
|
||||||
|
padding: "2px 2px 0px 2px",
|
||||||
|
"& img": {
|
||||||
|
borderTopLeftRadius: "10px",
|
||||||
|
borderTopRightRadius: "10px",
|
||||||
|
width: "100%",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaViewer media={articleMedia} fullWidth fullHeight />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 200,
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.1)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImagePlus size={48} color="white" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "120%",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
borderBottom: "1px solid #A89F90",
|
||||||
|
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||||
|
background:
|
||||||
|
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="white">
|
||||||
|
{article.heading || "Выберите статью"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
padding: 1,
|
||||||
|
minHeight: "200px",
|
||||||
|
maxHeight: "300px",
|
||||||
|
overflowY: "scroll",
|
||||||
|
background:
|
||||||
|
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.body ? (
|
||||||
|
<ReactMarkdownComponent value={article.body} />
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
color="rgba(255,255,255,0.7)"
|
||||||
|
sx={{ textAlign: "center", mt: 4 }}
|
||||||
|
>
|
||||||
|
Предпросмотр статьи появится здесь
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{articleData?.right && articleData?.right.length > 1 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "120%",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 1,
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||||
|
background:
|
||||||
|
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{articleData.right.map((a, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className="inline-block text-left text-xs text-white"
|
||||||
|
>
|
||||||
|
{a.heading}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
||||||
80
src/pages/Article/ArticlePreviewPage/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { PreviewLeftWidget } from "./PreviewLeftWidget";
|
||||||
|
import { PreviewRightWidget } from "./PreviewRightWidget";
|
||||||
|
import { articlesStore, languageStore, LoadingSpinner } from "@shared";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export const ArticlePreviewPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (id) {
|
||||||
|
setIsLoadingData(true);
|
||||||
|
try {
|
||||||
|
await getArticle(Number(id), language);
|
||||||
|
await getArticleMedia(Number(id));
|
||||||
|
await getArticlePreview(Number(id));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [id, language]);
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных статьи..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4 mb-10">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
p: 2,
|
||||||
|
justifyContent: "center",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: "320px" }}>
|
||||||
|
<PreviewLeftWidget />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ width: "500px" }}>
|
||||||
|
<PreviewRightWidget />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./ArticleListPage";
|
export * from "./ArticleListPage";
|
||||||
|
export * from "./ArticlePreviewPage";
|
||||||
|
|||||||
@@ -12,41 +12,60 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { carrierStore, cityStore, mediaStore } from "@shared";
|
import {
|
||||||
|
carrierStore,
|
||||||
|
cityStore,
|
||||||
|
mediaStore,
|
||||||
|
languageStore,
|
||||||
|
useSelectedCity,
|
||||||
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { MediaViewer } from "@widgets";
|
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||||
|
import {
|
||||||
|
SelectMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
|
PreviewMediaDialog,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
export const CarrierCreatePage = observer(() => {
|
export const CarrierCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [fullName, setFullName] = useState("");
|
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||||
const [shortName, setShortName] = useState("");
|
const { language } = languageStore;
|
||||||
const [cityId, setCityId] = useState<number | null>(null);
|
const { selectedCityId } = useSelectedCity();
|
||||||
const [main_color, setMainColor] = useState("#000000");
|
|
||||||
const [left_color, setLeftColor] = useState("#ffffff");
|
|
||||||
const [right_color, setRightColor] = useState("#ff0000");
|
|
||||||
const [slogan, setSlogan] = useState("");
|
|
||||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
|
const [mediaId, setMediaId] = useState("");
|
||||||
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cityStore.getCities("ru");
|
cityStore.getCities("ru");
|
||||||
mediaStore.getMedia();
|
mediaStore.getMedia();
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCityId && !createCarrierData.city_id) {
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
selectedCityId,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedCityId, createCarrierData.city_id]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await carrierStore.createCarrier(
|
await carrierStore.createCarrier();
|
||||||
fullName,
|
|
||||||
shortName,
|
|
||||||
cityStore.cities.ru.find((c) => c.id === cityId)?.name!,
|
|
||||||
cityId!,
|
|
||||||
main_color,
|
|
||||||
left_color,
|
|
||||||
right_color,
|
|
||||||
slogan,
|
|
||||||
selectedMediaId!
|
|
||||||
);
|
|
||||||
toast.success("Перевозчик успешно создан");
|
toast.success("Перевозчик успешно создан");
|
||||||
navigate("/carrier");
|
navigate("/carrier");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,8 +75,30 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMediaSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setSelectedMediaId(media.id);
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
media.id,
|
||||||
|
language
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMedia = selectedMediaId
|
||||||
|
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<LanguageSwitcher />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -69,15 +110,28 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words">Создание перевозчика</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Город</InputLabel>
|
<InputLabel>Город</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={cityId || ""}
|
value={createCarrierData.city_id || ""}
|
||||||
label="Город"
|
label="Город"
|
||||||
required
|
required
|
||||||
onChange={(e) => setCityId(e.target.value as number)}
|
onChange={(e) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
e.target.value as number,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{cityStore.cities.ru.map((city) => (
|
{cityStore.cities["ru"].data.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -88,100 +142,83 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Полное название"
|
label="Полное название"
|
||||||
value={fullName}
|
value={createCarrierData[language].full_name}
|
||||||
required
|
required
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
e.target.value,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Короткое название"
|
label="Короткое название"
|
||||||
value={shortName}
|
value={createCarrierData[language].short_name}
|
||||||
required
|
required
|
||||||
onChange={(e) => setShortName(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
e.target.value,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-4 w-full ">
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Основной цвет"
|
|
||||||
value={main_color}
|
|
||||||
className="flex-1 w-full"
|
|
||||||
onChange={(e) => setMainColor(e.target.value)}
|
|
||||||
type="color"
|
|
||||||
sx={{
|
|
||||||
"& input": {
|
|
||||||
height: "50px",
|
|
||||||
paddingBlock: "14px",
|
|
||||||
paddingInline: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Цвет левого виджета"
|
|
||||||
value={left_color}
|
|
||||||
className="flex-1 w-full"
|
|
||||||
onChange={(e) => setLeftColor(e.target.value)}
|
|
||||||
type="color"
|
|
||||||
sx={{
|
|
||||||
"& input": {
|
|
||||||
height: "50px",
|
|
||||||
paddingBlock: "14px",
|
|
||||||
paddingInline: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Цвет правого виджета"
|
|
||||||
value={right_color}
|
|
||||||
className="flex-1 w-full"
|
|
||||||
onChange={(e) => setRightColor(e.target.value)}
|
|
||||||
type="color"
|
|
||||||
sx={{
|
|
||||||
"& input": {
|
|
||||||
height: "50px",
|
|
||||||
paddingBlock: "14px",
|
|
||||||
paddingInline: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Слоган"
|
label="Слоган"
|
||||||
value={slogan}
|
value={createCarrierData[language].slogan}
|
||||||
onChange={(e) => setSlogan(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
e.target.value,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<FormControl fullWidth>
|
<ImageUploadCard
|
||||||
<InputLabel>Логотип</InputLabel>
|
title="Логотип перевозчика"
|
||||||
<Select
|
imageKey="thumbnail"
|
||||||
value={selectedMediaId || ""}
|
imageUrl={selectedMedia?.id}
|
||||||
label="Логотип"
|
onImageClick={() => {
|
||||||
required
|
setIsPreviewMediaOpen(true);
|
||||||
onChange={(e) => setSelectedMediaId(e.target.value as string)}
|
setMediaId(selectedMedia?.id ?? "");
|
||||||
>
|
}}
|
||||||
{mediaStore.media
|
onDeleteImageClick={() => {
|
||||||
.filter((media) => media.media_type === 3)
|
setSelectedMediaId(null);
|
||||||
.map((media) => (
|
setActiveMenuType(null);
|
||||||
<MenuItem key={media.id} value={media.id}>
|
setCreateCarrierData(
|
||||||
{media.media_name || media.filename}
|
createCarrierData[language].full_name,
|
||||||
</MenuItem>
|
createCarrierData[language].short_name,
|
||||||
))}
|
createCarrierData.city_id,
|
||||||
</Select>
|
createCarrierData[language].slogan,
|
||||||
</FormControl>
|
"",
|
||||||
{selectedMediaId && (
|
language
|
||||||
<div className="w-32 h-32">
|
);
|
||||||
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} />
|
}}
|
||||||
</div>
|
onSelectFileClick={() => {
|
||||||
)}
|
setActiveMenuType("image");
|
||||||
|
setIsSelectMediaOpen(true);
|
||||||
|
}}
|
||||||
|
setUploadMediaOpen={() => {
|
||||||
|
setIsUploadMediaOpen(true);
|
||||||
|
setActiveMenuType("image");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -190,7 +227,10 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
|
isLoading ||
|
||||||
|
!createCarrierData[language].full_name ||
|
||||||
|
!createCarrierData[language].short_name ||
|
||||||
|
!createCarrierData.city_id
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -200,6 +240,28 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectMediaDialog
|
||||||
|
open={isSelectMediaOpen}
|
||||||
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
|
onSelectMedia={handleMediaSelect}
|
||||||
|
mediaType={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadMediaOpen}
|
||||||
|
onClose={() => setIsUploadMediaOpen(false)}
|
||||||
|
contextObjectName={createCarrierData[language].full_name}
|
||||||
|
contextType="carrier"
|
||||||
|
afterUpload={handleMediaSelect}
|
||||||
|
hardcodeType={activeMenuType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewMediaDialog
|
||||||
|
open={isPreviewMediaOpen}
|
||||||
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
|
mediaId={mediaId}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,41 +6,92 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
Box,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { carrierStore, cityStore, mediaStore } from "@shared";
|
import {
|
||||||
|
carrierStore,
|
||||||
|
cityStore,
|
||||||
|
mediaStore,
|
||||||
|
languageStore,
|
||||||
|
LoadingSpinner,
|
||||||
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { MediaViewer } from "@widgets";
|
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||||
|
import {
|
||||||
|
SelectMediaDialog,
|
||||||
|
PreviewMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
export const CarrierEditPage = observer(() => {
|
export const CarrierEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { carrier, getCarrier, setEditCarrierData, editCarrierData } =
|
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||||
carrierStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
|
const [mediaId, setMediaId] = useState("");
|
||||||
|
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||||
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await getCarrier(Number(id));
|
if (!id) {
|
||||||
setEditCarrierData(
|
setIsLoadingData(false);
|
||||||
carrier?.[Number(id)]?.full_name as string,
|
return;
|
||||||
carrier?.[Number(id)]?.short_name as string,
|
}
|
||||||
carrier?.[Number(id)]?.city as string,
|
setIsLoadingData(true);
|
||||||
carrier?.[Number(id)]?.city_id as number,
|
try {
|
||||||
carrier?.[Number(id)]?.main_color as string,
|
await cityStore.getCities("ru");
|
||||||
carrier?.[Number(id)]?.left_color as string,
|
await cityStore.getCities("en");
|
||||||
carrier?.[Number(id)]?.right_color as string,
|
await cityStore.getCities("zh");
|
||||||
carrier?.[Number(id)]?.slogan as string,
|
const carrierData = await getCarrier(Number(id));
|
||||||
carrier?.[Number(id)]?.logo as string
|
|
||||||
);
|
if (carrierData) {
|
||||||
cityStore.getCities("ru");
|
setEditCarrierData(
|
||||||
mediaStore.getMedia();
|
carrierData.ru?.full_name || "",
|
||||||
|
carrierData.ru?.short_name || "",
|
||||||
|
carrierData.ru?.city_id || 0,
|
||||||
|
carrierData.ru?.slogan || "",
|
||||||
|
carrierData.ru?.logo || "",
|
||||||
|
"ru"
|
||||||
|
);
|
||||||
|
setEditCarrierData(
|
||||||
|
carrierData.en?.full_name || "",
|
||||||
|
carrierData.en?.short_name || "",
|
||||||
|
carrierData.en?.city_id || 0,
|
||||||
|
carrierData.en?.slogan || "",
|
||||||
|
carrierData.en?.logo || "",
|
||||||
|
"en"
|
||||||
|
);
|
||||||
|
setEditCarrierData(
|
||||||
|
carrierData.zh?.full_name || "",
|
||||||
|
carrierData.zh?.short_name || "",
|
||||||
|
carrierData.zh?.city_id || 0,
|
||||||
|
carrierData.zh?.slogan || "",
|
||||||
|
carrierData.zh?.logo || "",
|
||||||
|
"zh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediaStore.getMedia();
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
@@ -56,8 +107,44 @@ export const CarrierEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMediaSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
media.id,
|
||||||
|
language
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMedia = editCarrierData.logo
|
||||||
|
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных перевозчика..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<LanguageSwitcher />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -68,6 +155,9 @@ export const CarrierEditPage = observer(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Город</InputLabel>
|
<InputLabel>Город</InputLabel>
|
||||||
@@ -77,19 +167,16 @@ export const CarrierEditPage = observer(() => {
|
|||||||
required
|
required
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
editCarrierData.full_name,
|
editCarrierData[language].full_name,
|
||||||
editCarrierData.short_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData.city,
|
|
||||||
Number(e.target.value),
|
Number(e.target.value),
|
||||||
editCarrierData.main_color,
|
editCarrierData[language].slogan,
|
||||||
editCarrierData.left_color,
|
editCarrierData.logo,
|
||||||
editCarrierData.right_color,
|
language
|
||||||
editCarrierData.slogan,
|
|
||||||
editCarrierData.logo
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{cityStore.cities.ru.map((city) => (
|
{cityStore.cities["ru"].data?.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -100,19 +187,16 @@ export const CarrierEditPage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Полное название"
|
label="Полное название"
|
||||||
value={editCarrierData.full_name}
|
value={editCarrierData[language].full_name}
|
||||||
required
|
required
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData.short_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData.city,
|
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
editCarrierData.main_color,
|
editCarrierData[language].slogan,
|
||||||
editCarrierData.left_color,
|
editCarrierData.logo,
|
||||||
editCarrierData.right_color,
|
language
|
||||||
editCarrierData.slogan,
|
|
||||||
editCarrierData.logo
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -120,166 +204,57 @@ export const CarrierEditPage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Короткое название"
|
label="Короткое название"
|
||||||
value={editCarrierData.short_name}
|
value={editCarrierData[language].short_name}
|
||||||
required
|
required
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
editCarrierData.full_name,
|
editCarrierData[language].full_name,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData.city,
|
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
editCarrierData.main_color,
|
editCarrierData[language].slogan,
|
||||||
editCarrierData.left_color,
|
editCarrierData.logo,
|
||||||
editCarrierData.right_color,
|
language
|
||||||
editCarrierData.slogan,
|
|
||||||
editCarrierData.logo
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-4 w-full">
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Основной цвет"
|
|
||||||
value={editCarrierData.main_color}
|
|
||||||
className="flex-1 w-full"
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData.full_name,
|
|
||||||
editCarrierData.short_name,
|
|
||||||
editCarrierData.city,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
e.target.value,
|
|
||||||
editCarrierData.left_color,
|
|
||||||
editCarrierData.right_color,
|
|
||||||
editCarrierData.slogan,
|
|
||||||
editCarrierData.logo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="color"
|
|
||||||
sx={{
|
|
||||||
"& input": {
|
|
||||||
height: "50px",
|
|
||||||
paddingBlock: "14px",
|
|
||||||
paddingInline: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Цвет левого виджета"
|
|
||||||
value={editCarrierData.left_color}
|
|
||||||
className="flex-1 w-full"
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData.full_name,
|
|
||||||
editCarrierData.short_name,
|
|
||||||
editCarrierData.city,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
editCarrierData.main_color,
|
|
||||||
e.target.value,
|
|
||||||
editCarrierData.right_color,
|
|
||||||
editCarrierData.slogan,
|
|
||||||
editCarrierData.logo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="color"
|
|
||||||
sx={{
|
|
||||||
"& input": {
|
|
||||||
height: "50px",
|
|
||||||
paddingBlock: "14px",
|
|
||||||
paddingInline: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Цвет правого виджета"
|
|
||||||
value={editCarrierData.right_color}
|
|
||||||
className="flex-1 w-full"
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData.full_name,
|
|
||||||
editCarrierData.short_name,
|
|
||||||
editCarrierData.city,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
editCarrierData.main_color,
|
|
||||||
editCarrierData.left_color,
|
|
||||||
e.target.value,
|
|
||||||
editCarrierData.slogan,
|
|
||||||
editCarrierData.logo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="color"
|
|
||||||
sx={{
|
|
||||||
"& input": {
|
|
||||||
height: "50px",
|
|
||||||
paddingBlock: "14px",
|
|
||||||
paddingInline: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Слоган"
|
label="Слоган"
|
||||||
value={editCarrierData.slogan}
|
value={editCarrierData[language].slogan}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
editCarrierData.full_name,
|
editCarrierData[language].full_name,
|
||||||
editCarrierData.short_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData.city,
|
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
editCarrierData.main_color,
|
|
||||||
editCarrierData.left_color,
|
|
||||||
editCarrierData.right_color,
|
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData.logo
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<FormControl fullWidth>
|
<ImageUploadCard
|
||||||
<InputLabel>Логотип</InputLabel>
|
title="Логотип перевозчика"
|
||||||
<Select
|
imageKey="thumbnail"
|
||||||
value={editCarrierData.logo || ""}
|
imageUrl={selectedMedia?.id}
|
||||||
label="Логотип"
|
onImageClick={() => {
|
||||||
required
|
setIsPreviewMediaOpen(true);
|
||||||
onChange={(e) =>
|
setMediaId(selectedMedia?.id ?? "");
|
||||||
setEditCarrierData(
|
}}
|
||||||
editCarrierData.full_name,
|
onDeleteImageClick={() => {
|
||||||
editCarrierData.short_name,
|
setIsDeleteLogoModalOpen(true);
|
||||||
editCarrierData.city,
|
}}
|
||||||
editCarrierData.city_id,
|
onSelectFileClick={() => {
|
||||||
editCarrierData.main_color,
|
setActiveMenuType("image");
|
||||||
editCarrierData.left_color,
|
setIsSelectMediaOpen(true);
|
||||||
editCarrierData.right_color,
|
}}
|
||||||
editCarrierData.slogan,
|
setUploadMediaOpen={() => {
|
||||||
e.target.value as string
|
setIsUploadMediaOpen(true);
|
||||||
)
|
setActiveMenuType("image");
|
||||||
}
|
}}
|
||||||
>
|
/>
|
||||||
{mediaStore.media
|
|
||||||
.filter((media) => media.media_type === 3)
|
|
||||||
.map((media) => (
|
|
||||||
<MenuItem key={media.id} value={media.id}>
|
|
||||||
{media.media_name || media.filename}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
{editCarrierData.logo && (
|
|
||||||
<div className="w-32 h-32">
|
|
||||||
<MediaViewer
|
|
||||||
media={{ id: editCarrierData.logo, media_type: 1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -289,19 +264,56 @@ export const CarrierEditPage = observer(() => {
|
|||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading ||
|
||||||
!editCarrierData.full_name ||
|
!editCarrierData[language].full_name ||
|
||||||
!editCarrierData.short_name ||
|
!editCarrierData.city_id
|
||||||
!editCarrierData.city_id ||
|
|
||||||
!editCarrierData.logo
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Обновить"
|
"Сохранить"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectMediaDialog
|
||||||
|
open={isSelectMediaOpen}
|
||||||
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
|
onSelectMedia={handleMediaSelect}
|
||||||
|
mediaType={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadMediaOpen}
|
||||||
|
onClose={() => setIsUploadMediaOpen(false)}
|
||||||
|
contextObjectName={editCarrierData[language].full_name}
|
||||||
|
contextType="carrier"
|
||||||
|
afterUpload={handleMediaSelect}
|
||||||
|
hardcodeType={activeMenuType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewMediaDialog
|
||||||
|
open={isPreviewMediaOpen}
|
||||||
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
|
mediaId={mediaId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteLogoModalOpen}
|
||||||
|
onDelete={() => {
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
"",
|
||||||
|
language
|
||||||
|
);
|
||||||
|
setIsDeleteLogoModalOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsDeleteLogoModalOpen(false)}
|
||||||
|
edit
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +1,94 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { carrierStore } from "@shared";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
|
import { carrierStore, cityStore, languageStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal } from "@widgets";
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const CarrierListPage = observer(() => {
|
export const CarrierListPage = observer(() => {
|
||||||
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
||||||
|
const { getCities, cities } = cityStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCarriers();
|
const fetchData = async () => {
|
||||||
}, []);
|
setIsLoading(true);
|
||||||
|
await getCities("ru");
|
||||||
|
await getCities("en");
|
||||||
|
await getCities("zh");
|
||||||
|
await getCarriers(language);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
field: "full_name",
|
field: "full_name",
|
||||||
headerName: "Полное имя",
|
headerName: "Полное имя",
|
||||||
|
|
||||||
width: 300,
|
width: 300,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "short_name",
|
field: "short_name",
|
||||||
headerName: "Короткое имя",
|
headerName: "Короткое имя",
|
||||||
width: 200,
|
width: 200,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "city",
|
field: "city_id",
|
||||||
headerName: "Город",
|
headerName: "Город",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
const city = cities[language]?.data.find(
|
||||||
|
(city) => city.id == params.value
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{city && city.name ? (
|
||||||
|
city.name
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
width: 200,
|
width: 200,
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -45,9 +96,9 @@ export const CarrierListPage = observer(() => {
|
|||||||
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button> */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -62,25 +113,56 @@ export const CarrierListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = carriers.data?.map((carrier) => ({
|
const rows = carriers[language].data?.map((carrier) => ({
|
||||||
id: carrier.id,
|
id: carrier.id,
|
||||||
full_name: carrier.full_name,
|
full_name: carrier.full_name,
|
||||||
short_name: carrier.short_name,
|
short_name: carrier.short_name,
|
||||||
city: carrier.city,
|
city_id: carrier.city_id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Перевозчики</h1>
|
<h1 className="text-2xl">Перевозчики</h1>
|
||||||
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
|
||||||
hideFooter
|
hideFooter
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||||
|
}}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
"Нет перевозчиков"
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +180,19 @@ export const CarrierListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteCarrier(id)));
|
||||||
|
await getCarriers(language);
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Paper } from "@mui/material";
|
|
||||||
import { carrierStore, mediaStore } from "@shared";
|
|
||||||
import { MediaViewer } from "@widgets";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
export const CarrierPreviewPage = observer(() => {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { getCarrier, carrier, setEditCarrierData } = carrierStore;
|
|
||||||
const { oneMedia, getOneMedia } = mediaStore;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const carrierResponse = await getCarrier(Number(id));
|
|
||||||
setEditCarrierData(
|
|
||||||
carrierResponse?.full_name as string,
|
|
||||||
carrierResponse?.short_name as string,
|
|
||||||
carrierResponse?.city as string,
|
|
||||||
carrierResponse?.city_id as number,
|
|
||||||
carrierResponse?.main_color as string,
|
|
||||||
carrierResponse?.left_color as string,
|
|
||||||
carrierResponse?.right_color as string,
|
|
||||||
carrierResponse?.slogan as string,
|
|
||||||
carrierResponse?.logo as string
|
|
||||||
);
|
|
||||||
console.log(carrierResponse);
|
|
||||||
await getOneMedia(carrierResponse?.logo as string);
|
|
||||||
})();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
|
||||||
{carrier && (
|
|
||||||
<>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
Назад
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-10 w-full">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Полное имя</h1>
|
|
||||||
<p>{carrier[Number(id)]?.full_name}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Полное имя</h1>
|
|
||||||
<p>{carrier[Number(id)]?.full_name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Город</h1>
|
|
||||||
<p>{carrier[Number(id)]?.city}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 ">
|
|
||||||
<h1 className="text-lg font-bold">Основной цвет</h1>
|
|
||||||
<div
|
|
||||||
className="w-min"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${carrier[Number(id)]?.main_color}90`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{carrier[Number(id)]?.main_color}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Цвет левого виджета</h1>
|
|
||||||
<div
|
|
||||||
className="w-min"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${carrier[Number(id)]?.left_color}90`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{carrier[Number(id)]?.left_color}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Цвет правого виджета</h1>
|
|
||||||
<div
|
|
||||||
className="w-min"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${carrier[Number(id)]?.right_color}90`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{carrier[Number(id)]?.right_color}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Краткое имя</h1>
|
|
||||||
<p>{carrier[Number(id)]?.short_name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-bold">Логотип</h1>
|
|
||||||
|
|
||||||
<MediaViewer
|
|
||||||
media={{
|
|
||||||
id: oneMedia?.id as string,
|
|
||||||
media_type: oneMedia?.media_type as number,
|
|
||||||
filename: oneMedia?.filename,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export * from "./CarrierListPage";
|
export * from "./CarrierListPage";
|
||||||
export * from "./CarrierPreviewPage";
|
|
||||||
export * from "./CarrierCreatePage";
|
export * from "./CarrierCreatePage";
|
||||||
export * from "./CarrierEditPage";
|
export * from "./CarrierEditPage";
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Box,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
|
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LanguageSwitcher, MediaViewer } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
import { SelectMediaDialog } from "@shared";
|
import {
|
||||||
|
SelectMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
|
PreviewMediaDialog,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
export const CityCreatePage = observer(() => {
|
export const CityCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -24,12 +27,20 @@ export const CityCreatePage = observer(() => {
|
|||||||
const { createCityData, setCreateCityData } = cityStore;
|
const { createCityData, setCreateCityData } = cityStore;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
|
const [mediaId, setMediaId] = useState("");
|
||||||
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
|
>(null);
|
||||||
const { getCountries } = countryStore;
|
const { getCountries } = countryStore;
|
||||||
const { getMedia } = mediaStore;
|
const { getMedia } = mediaStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await getCountries(language);
|
await getCountries("ru");
|
||||||
|
await getCountries("en");
|
||||||
|
await getCountries("zh");
|
||||||
await getMedia();
|
await getMedia();
|
||||||
})();
|
})();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
@@ -55,7 +66,6 @@ export const CityCreatePage = observer(() => {
|
|||||||
}) => {
|
}) => {
|
||||||
setCreateCityData(
|
setCreateCityData(
|
||||||
createCityData[language].name,
|
createCityData[language].name,
|
||||||
createCityData.country,
|
|
||||||
createCityData.country_code,
|
createCityData.country_code,
|
||||||
media.id,
|
media.id,
|
||||||
language
|
language
|
||||||
@@ -80,6 +90,9 @@ export const CityCreatePage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words">{createCityData.ru.name}</h1>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Название города"
|
label="Название города"
|
||||||
@@ -88,7 +101,6 @@ export const CityCreatePage = observer(() => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCreateCityData(
|
setCreateCityData(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
createCityData.country,
|
|
||||||
createCityData.country_code,
|
createCityData.country_code,
|
||||||
createCityData.arms,
|
createCityData.arms,
|
||||||
language
|
language
|
||||||
@@ -103,19 +115,15 @@ export const CityCreatePage = observer(() => {
|
|||||||
label="Страна"
|
label="Страна"
|
||||||
required
|
required
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selectedCountry = countryStore.countries[language]?.find(
|
|
||||||
(country) => country.code === e.target.value
|
|
||||||
);
|
|
||||||
setCreateCityData(
|
setCreateCityData(
|
||||||
createCityData[language].name,
|
createCityData[language].name,
|
||||||
selectedCountry?.name || "",
|
|
||||||
e.target.value,
|
e.target.value,
|
||||||
createCityData.arms,
|
createCityData.arms,
|
||||||
language
|
language
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{countryStore.countries[language].map((country) => (
|
{countryStore.countries["ru"]?.data?.map((country) => (
|
||||||
<MenuItem key={country.code} value={country.code}>
|
<MenuItem key={country.code} value={country.code}>
|
||||||
{country.name}
|
{country.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -123,44 +131,36 @@ export const CityCreatePage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<label className="text-sm text-gray-600">Герб города</label>
|
<ImageUploadCard
|
||||||
<div className="flex items-center gap-4">
|
title="Герб города"
|
||||||
<Button
|
imageKey="image"
|
||||||
variant="outlined"
|
imageUrl={selectedMedia?.id}
|
||||||
onClick={() => setIsSelectMediaOpen(true)}
|
onImageClick={() => {
|
||||||
startIcon={<ImagePlus size={20} />}
|
setIsPreviewMediaOpen(true);
|
||||||
>
|
setMediaId(selectedMedia?.id ?? "");
|
||||||
Выбрать герб
|
}}
|
||||||
</Button>
|
onDeleteImageClick={() => {
|
||||||
{selectedMedia && (
|
setCreateCityData(
|
||||||
<span className="text-sm text-gray-600">
|
createCityData[language].name,
|
||||||
{selectedMedia.media_name || selectedMedia.filename}
|
createCityData.country_code,
|
||||||
</span>
|
"",
|
||||||
)}
|
language
|
||||||
</div>
|
);
|
||||||
{selectedMedia && (
|
setActiveMenuType(null);
|
||||||
<Box
|
}}
|
||||||
sx={{
|
onSelectFileClick={() => {
|
||||||
width: "200px",
|
setActiveMenuType("image");
|
||||||
height: "200px",
|
setIsSelectMediaOpen(true);
|
||||||
border: "1px solid #e0e0e0",
|
}}
|
||||||
borderRadius: "8px",
|
setUploadMediaOpen={() => {
|
||||||
overflow: "hidden",
|
setIsUploadMediaOpen(true);
|
||||||
display: "flex",
|
setActiveMenuType("image");
|
||||||
alignItems: "center",
|
}}
|
||||||
justifyContent: "center",
|
setHardcodeType={() => {
|
||||||
}}
|
setActiveMenuType("image");
|
||||||
>
|
}}
|
||||||
<MediaViewer
|
/>
|
||||||
media={{
|
|
||||||
id: selectedMedia.id,
|
|
||||||
media_type: selectedMedia.media_type,
|
|
||||||
filename: selectedMedia.filename,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -182,7 +182,24 @@ export const CityCreatePage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={3} // Тип медиа для иконок
|
mediaType={1} // Тип медиа для иконок
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadMediaOpen}
|
||||||
|
onClose={() => setIsUploadMediaOpen(false)}
|
||||||
|
contextObjectName={createCityData[language]?.name}
|
||||||
|
contextType="city"
|
||||||
|
afterUpload={handleMediaSelect}
|
||||||
|
hardcodeType={
|
||||||
|
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewMediaDialog
|
||||||
|
open={isPreviewMediaOpen}
|
||||||
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
@@ -19,21 +19,37 @@ import {
|
|||||||
languageStore,
|
languageStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
CashedCities,
|
CashedCities,
|
||||||
|
LoadingSpinner,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher, MediaViewer } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
import { SelectMediaDialog } from "@shared";
|
import {
|
||||||
|
SelectMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
|
PreviewMediaDialog,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
export const CityEditPage = observer(() => {
|
export const CityEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
|
const [mediaId, setMediaId] = useState("");
|
||||||
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
|
>(null);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
||||||
const { getCountries } = countryStore;
|
const { getCountries } = countryStore;
|
||||||
const { getMedia, getOneMedia } = mediaStore;
|
const { getMedia, getOneMedia } = mediaStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -49,20 +65,29 @@ export const CityEditPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const data = await getCity(id as string, language);
|
setIsLoadingData(true);
|
||||||
setEditCityData(
|
try {
|
||||||
data.name,
|
await getCountries("ru");
|
||||||
data.country,
|
|
||||||
data.country_code,
|
const ruData = await getCity(id as string, "ru");
|
||||||
data.arms,
|
const enData = await getCity(id as string, "en");
|
||||||
language
|
const zhData = await getCity(id as string, "zh");
|
||||||
);
|
|
||||||
await getOneMedia(data.arms as string);
|
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||||
await getCountries(language);
|
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||||
await getMedia();
|
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||||
|
|
||||||
|
await getOneMedia(ruData.arms as string);
|
||||||
|
|
||||||
|
await getMedia();
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoadingData(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id, language]);
|
}, [id]);
|
||||||
|
|
||||||
const handleMediaSelect = (media: {
|
const handleMediaSelect = (media: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,7 +97,6 @@ export const CityEditPage = observer(() => {
|
|||||||
}) => {
|
}) => {
|
||||||
setEditCityData(
|
setEditCityData(
|
||||||
editCityData[language].name,
|
editCityData[language].name,
|
||||||
editCityData.country,
|
|
||||||
editCityData.country_code,
|
editCityData.country_code,
|
||||||
media.id,
|
media.id,
|
||||||
language
|
language
|
||||||
@@ -83,6 +107,21 @@ export const CityEditPage = observer(() => {
|
|||||||
? mediaStore.media.find((m) => m.id === editCityData.arms)
|
? mediaStore.media.find((m) => m.id === editCityData.arms)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных города..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
@@ -97,6 +136,9 @@ export const CityEditPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start ">
|
||||||
|
<h1 className="text-3xl break-words">{editCityData.ru.name}</h1>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Название"
|
label="Название"
|
||||||
@@ -105,7 +147,6 @@ export const CityEditPage = observer(() => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditCityData(
|
setEditCityData(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCityData.country,
|
|
||||||
editCityData.country_code,
|
editCityData.country_code,
|
||||||
editCityData.arms,
|
editCityData.arms,
|
||||||
language
|
language
|
||||||
@@ -120,19 +161,15 @@ export const CityEditPage = observer(() => {
|
|||||||
label="Страна"
|
label="Страна"
|
||||||
required
|
required
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selectedCountry = countryStore.countries[language]?.find(
|
|
||||||
(country) => country.code === e.target.value
|
|
||||||
);
|
|
||||||
setEditCityData(
|
setEditCityData(
|
||||||
editCityData[language as keyof CashedCities]?.name || "",
|
editCityData[language].name,
|
||||||
selectedCountry?.name || "",
|
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCityData.arms,
|
editCityData.arms,
|
||||||
language
|
language
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{countryStore.countries[language].map((country) => (
|
{countryStore.countries.ru.data.map((country) => (
|
||||||
<MenuItem key={country.code} value={country.code}>
|
<MenuItem key={country.code} value={country.code}>
|
||||||
{country.name}
|
{country.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -140,44 +177,36 @@ export const CityEditPage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<label className="text-sm text-gray-600">Герб города</label>
|
<ImageUploadCard
|
||||||
<div className="flex items-center gap-4">
|
title="Герб города"
|
||||||
<Button
|
imageKey="image"
|
||||||
variant="outlined"
|
imageUrl={selectedMedia?.id}
|
||||||
onClick={() => setIsSelectMediaOpen(true)}
|
onImageClick={() => {
|
||||||
startIcon={<ImagePlus size={20} />}
|
setIsPreviewMediaOpen(true);
|
||||||
>
|
setMediaId(selectedMedia?.id ?? "");
|
||||||
Выбрать герб
|
}}
|
||||||
</Button>
|
onDeleteImageClick={() => {
|
||||||
{selectedMedia && (
|
setEditCityData(
|
||||||
<span className="text-sm text-gray-600">
|
editCityData[language].name,
|
||||||
{selectedMedia.media_name || selectedMedia.filename}
|
editCityData.country_code,
|
||||||
</span>
|
"",
|
||||||
)}
|
language
|
||||||
</div>
|
);
|
||||||
{selectedMedia && (
|
setActiveMenuType(null);
|
||||||
<Box
|
}}
|
||||||
sx={{
|
onSelectFileClick={() => {
|
||||||
width: "200px",
|
setActiveMenuType("image");
|
||||||
height: "200px",
|
setIsSelectMediaOpen(true);
|
||||||
border: "1px solid #e0e0e0",
|
}}
|
||||||
borderRadius: "8px",
|
setUploadMediaOpen={() => {
|
||||||
overflow: "hidden",
|
setIsUploadMediaOpen(true);
|
||||||
display: "flex",
|
setActiveMenuType("image");
|
||||||
alignItems: "center",
|
}}
|
||||||
justifyContent: "center",
|
setHardcodeType={() => {
|
||||||
}}
|
setActiveMenuType("image");
|
||||||
>
|
}}
|
||||||
<MediaViewer
|
/>
|
||||||
media={{
|
|
||||||
id: selectedMedia.id,
|
|
||||||
media_type: selectedMedia.media_type,
|
|
||||||
filename: selectedMedia.filename,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -192,7 +221,7 @@ export const CityEditPage = observer(() => {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Обновить"
|
"Сохранить"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,6 +230,29 @@ export const CityEditPage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
|
mediaType={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadMediaOpen}
|
||||||
|
onClose={() => setIsUploadMediaOpen(false)}
|
||||||
|
contextObjectName={editCityData[language].name}
|
||||||
|
contextType="city"
|
||||||
|
afterUpload={handleMediaSelect}
|
||||||
|
hardcodeType={
|
||||||
|
activeMenuType as
|
||||||
|
| "thumbnail"
|
||||||
|
| "watermark_lu"
|
||||||
|
| "watermark_rd"
|
||||||
|
| "image"
|
||||||
|
| null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewMediaDialog
|
||||||
|
open={isPreviewMediaOpen}
|
||||||
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,91 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { languageStore, cityStore, CashedCities } from "@shared";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
|
import { languageStore, cityStore, countryStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const CityListPage = observer(() => {
|
export const CityListPage = observer(() => {
|
||||||
const { cities, getCities, deleteCity } = cityStore;
|
const { cities, getCities, deleteCity } = cityStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<number | null>(null);
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCities(language);
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await countryStore.getCountries("ru");
|
||||||
|
await countryStore.getCountries("en");
|
||||||
|
await countryStore.getCountries("zh");
|
||||||
|
await getCities(language);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let newRows = cities[language]?.data?.map((city) => ({
|
||||||
|
id: city.id,
|
||||||
|
name: city.name,
|
||||||
|
country: city.country_code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let newRows2: any[] = [];
|
||||||
|
for (const city of newRows) {
|
||||||
|
const name = countryStore.countries[language]?.data?.find(
|
||||||
|
(country) => country.code === city.country
|
||||||
|
)?.name;
|
||||||
|
if (name) {
|
||||||
|
newRows2.push(city);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows(newRows2 || []);
|
||||||
|
}, [cities, countryStore.countries, language, isLoading]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
field: "country",
|
field: "country",
|
||||||
headerName: "Страна",
|
headerName: "Страна",
|
||||||
width: 150,
|
width: 150,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
countryStore.countries[language]?.data?.find(
|
||||||
|
(country) => country.code === params.value
|
||||||
|
)?.name
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
@@ -34,18 +93,19 @@ export const CityListPage = observer(() => {
|
|||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
width: 200,
|
width: 200,
|
||||||
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigate(`/city/${params.row.id}`)}>
|
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button> */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
}}
|
}}
|
||||||
@@ -58,12 +118,6 @@ export const CityListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = cities[language].map((city) => ({
|
|
||||||
id: city.id,
|
|
||||||
name: city.name,
|
|
||||||
country: city.country,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
@@ -73,11 +127,37 @@ export const CityListPage = observer(() => {
|
|||||||
<h1 className="text-2xl">Города</h1>
|
<h1 className="text-2xl">Города</h1>
|
||||||
<CreateButton label="Создать город" path="/city/create" />
|
<CreateButton label="Создать город" path="/city/create" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
|
||||||
hideFooter
|
hideFooter
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||||
|
}}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,7 +165,8 @@ export const CityListPage = observer(() => {
|
|||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (rowId) {
|
if (rowId) {
|
||||||
deleteCity(rowId.toString(), language as keyof CashedCities);
|
await deleteCity(rowId.toString());
|
||||||
|
toast.success("Город успешно удален");
|
||||||
}
|
}
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
setRowId(null);
|
setRowId(null);
|
||||||
@@ -95,6 +176,20 @@ export const CityListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteCity(id.toString())));
|
||||||
|
toast.success("Города успешно удалены");
|
||||||
|
getCities(language);
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ export const CityPreviewPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const cityResponse = await getCity(id as string, language);
|
const ruData = await getCity(id as string, "ru");
|
||||||
setEditCityData(
|
const enData = await getCity(id as string, "en");
|
||||||
cityResponse.name,
|
const zhData = await getCity(id as string, "zh");
|
||||||
cityResponse.country,
|
|
||||||
cityResponse.country_code,
|
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||||
cityResponse.arms,
|
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||||
language
|
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||||
);
|
|
||||||
await getOneMedia(cityResponse.arms as string);
|
await getOneMedia(ruData.arms as string);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id, language]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
|||||||
115
src/pages/Country/CountryAddPage/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
FormControl,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import {
|
||||||
|
countryStore,
|
||||||
|
RU_COUNTRIES,
|
||||||
|
EN_COUNTRIES,
|
||||||
|
ZH_COUNTRIES,
|
||||||
|
} from "@shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const CountryAddPage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|
||||||
|
const handleCountryCodeChange = (code: string) => {
|
||||||
|
const ruCountry = RU_COUNTRIES.find((c) => c.code === code);
|
||||||
|
const enCountry = EN_COUNTRIES.find((c) => c.code === code);
|
||||||
|
const zhCountry = ZH_COUNTRIES.find((c) => c.code === code);
|
||||||
|
|
||||||
|
if (ruCountry && enCountry && zhCountry) {
|
||||||
|
setCountryData(code, ruCountry.name, "ru");
|
||||||
|
setCountryData(code, enCountry.name, "en");
|
||||||
|
setCountryData(code, zhCountry.name, "zh");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await createCountry();
|
||||||
|
toast.success("Страна успешно создана");
|
||||||
|
navigate("/country");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании страны");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<Autocomplete
|
||||||
|
value={
|
||||||
|
RU_COUNTRIES.find((c) => c.code === createCountryData.code) ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
handleCountryCodeChange(newValue.code);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={RU_COUNTRIES}
|
||||||
|
getOptionLabel={(option) => `${option.code} - ${option.name}`}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Страна"
|
||||||
|
required
|
||||||
|
inputProps={{
|
||||||
|
...params.inputProps,
|
||||||
|
maxLength: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
filterOptions={(options, { inputValue }) => {
|
||||||
|
const searchValue = inputValue.toUpperCase();
|
||||||
|
return options.filter(
|
||||||
|
(option) =>
|
||||||
|
option.code.includes(searchValue) ||
|
||||||
|
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !createCountryData.code}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -41,6 +41,9 @@ export const CountryCreatePage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words">{createCountryData.ru.name}</h1>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Код страны"
|
label="Код страны"
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import { Button, Paper, TextField } from "@mui/material";
|
import { Button, Paper, TextField, Box } from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore } from "@shared";
|
import { countryStore, languageStore, LoadingSpinner } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryEditPage = observer(() => {
|
export const CountryEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editCountryData, editCountry, getCountry, setEditCountryData } =
|
const { editCountryData, editCountry, getCountry, setEditCountryData } =
|
||||||
countryStore;
|
countryStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -31,11 +36,38 @@ export const CountryEditPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const data = await getCountry(id as string, language);
|
setIsLoadingData(true);
|
||||||
setEditCountryData(data.name, language);
|
try {
|
||||||
|
const ruData = await getCountry(id as string, "ru");
|
||||||
|
const enData = await getCountry(id as string, "en");
|
||||||
|
const zhData = await getCountry(id as string, "zh");
|
||||||
|
|
||||||
|
setEditCountryData(ruData.name, "ru");
|
||||||
|
setEditCountryData(enData.name, "en");
|
||||||
|
setEditCountryData(zhData.name, "zh");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoadingData(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id, language]);
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных страны..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
@@ -51,6 +83,9 @@ export const CountryEditPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words t">{editCountryData.ru.name}</h1>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Код страны"
|
label="Код страны"
|
||||||
@@ -78,7 +113,7 @@ export const CountryEditPage = observer(() => {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Обновить"
|
"Сохранить"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { countryStore, languageStore } from "@shared";
|
import { countryStore, languageStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
import { Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const CountryListPage = observer(() => {
|
export const CountryListPage = observer(() => {
|
||||||
const { countries, getCountries } = countryStore;
|
const { countries, getCountries, deleteCountry } = countryStore;
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCountries(language);
|
const fetchCountries = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getCountries(language);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchCountries();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -22,6 +32,17 @@ export const CountryListPage = observer(() => {
|
|||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center ">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
@@ -29,19 +50,21 @@ export const CountryListPage = observer(() => {
|
|||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
width: 200,
|
width: 200,
|
||||||
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button
|
{/* <button
|
||||||
onClick={() => navigate(`/country/${params.row.code}/edit`)}
|
onClick={() => navigate(`/country/${params.row.code}/edit`)}
|
||||||
>
|
>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button> */}
|
||||||
<button onClick={() => navigate(`/country/${params.row.code}`)}>
|
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button> */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.code);
|
setRowId(params.row.code);
|
||||||
}}
|
}}
|
||||||
@@ -54,7 +77,7 @@ export const CountryListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = countries[language]?.map((country) => ({
|
const rows = countries[language]?.data.map((country) => ({
|
||||||
id: country.code,
|
id: country.code,
|
||||||
code: country.code,
|
code: country.code,
|
||||||
name: country.name,
|
name: country.name,
|
||||||
@@ -67,25 +90,66 @@ export const CountryListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Страны</h1>
|
<h1 className="text-2xl">Страны</h1>
|
||||||
<CreateButton label="Создать страну" path="/country/create" />
|
<CreateButton label="Добавить страну" path="/country/add" />
|
||||||
</div>
|
</div>
|
||||||
<DataGrid rows={rows} columns={columns} hideFooter />
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataGrid
|
||||||
|
rows={rows || []}
|
||||||
|
columns={columns}
|
||||||
|
hideFooter
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||||
|
}}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeleteModal
|
<DeleteModal
|
||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (rowId) {
|
if (!rowId) return;
|
||||||
await countryStore.deleteCountry(rowId, language);
|
await deleteCountry(rowId);
|
||||||
getCountries(language); // Refresh the list after deletion
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
}
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
setRowId(null);
|
setRowId(null);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
setRowId(null);
|
setRowId(null);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteCountry(id.toString())));
|
||||||
|
getCountries(language);
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ export const CountryPreviewPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const data = await getCountry(id as string, language);
|
const ruData = await getCountry(id as string, "ru");
|
||||||
setEditCountryData(data.name, language);
|
const enData = await getCountry(id as string, "en");
|
||||||
|
const zhData = await getCountry(id as string, "zh");
|
||||||
|
|
||||||
|
setEditCountryData(ruData.name, "ru");
|
||||||
|
setEditCountryData(enData.name, "en");
|
||||||
|
setEditCountryData(zhData.name, "zh");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id, language]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
@@ -55,7 +60,7 @@ export const CountryPreviewPage = observer(() => {
|
|||||||
<div className="flex flex-col gap-10 w-full">
|
<div className="flex flex-col gap-10 w-full">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-lg font-bold">Название</h1>
|
<h1 className="text-lg font-bold">Название</h1>
|
||||||
<p>{country[id!]?.[language]?.name}</p>
|
<p>{country[id!]?.ru?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./CountryListPage";
|
|||||||
export * from "./CountryPreviewPage";
|
export * from "./CountryPreviewPage";
|
||||||
export * from "./CountryCreatePage";
|
export * from "./CountryCreatePage";
|
||||||
export * from "./CountryEditPage";
|
export * from "./CountryEditPage";
|
||||||
|
export * from "./CountryAddPage";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
articlesStore,
|
articlesStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
languageStore,
|
LoadingSpinner,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useBlocker, useParams } from "react-router-dom";
|
import { useBlocker, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -20,11 +20,12 @@ function a11yProps(index: number) {
|
|||||||
|
|
||||||
export const EditSightPage = observer(() => {
|
export const EditSightPage = observer(() => {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
const { language } = languageStore;
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getRuCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
|
|
||||||
let blocker = useBlocker(
|
let blocker = useBlocker(
|
||||||
({ currentLocation, nextLocation }) =>
|
({ currentLocation, nextLocation }) =>
|
||||||
@@ -38,13 +39,26 @@ export const EditSightPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
await getSightInfo(+id, language);
|
setIsLoadingData(true);
|
||||||
await getArticles(language);
|
try {
|
||||||
await getRuCities();
|
await getCities("ru");
|
||||||
|
await getSightInfo(+id, "ru");
|
||||||
|
await getSightInfo(+id, "en");
|
||||||
|
await getSightInfo(+id, "zh");
|
||||||
|
await getArticles("ru");
|
||||||
|
await getArticles("en");
|
||||||
|
await getArticles("zh");
|
||||||
|
// Загружаем данные правого виджета перед завершением загрузки
|
||||||
|
await getRightArticles(+id);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoadingData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [id, language]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -80,12 +94,25 @@ export const EditSightPage = observer(() => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{sight.common.id !== 0 && (
|
{isLoadingData ? (
|
||||||
<div className="flex-1">
|
<Box
|
||||||
<InformationTab value={value} index={0} />
|
sx={{
|
||||||
<LeftWidgetTab value={value} index={1} />
|
display: "flex",
|
||||||
<RightWidgetTab value={value} index={2} />
|
justifyContent: "center",
|
||||||
</div>
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных достопримечательности..." />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
sight.common.id !== 0 && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<InformationTab value={value} index={0} />
|
||||||
|
<LeftWidgetTab value={value} index={1} />
|
||||||
|
<RightWidgetTab value={value} index={2} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Alert,
|
Alert,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Paper,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { authStore } from "@shared";
|
import { authStore, userStore } from "@shared";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
@@ -15,9 +18,20 @@ export const LoginPage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { login } = authStore;
|
const { login } = authStore;
|
||||||
|
const { getUsers } = userStore;
|
||||||
|
useEffect(() => {
|
||||||
|
const savedEmail = localStorage.getItem("rememberedEmail");
|
||||||
|
const savedPassword = localStorage.getItem("rememberedPassword");
|
||||||
|
if (savedEmail && savedPassword) {
|
||||||
|
setEmail(savedEmail);
|
||||||
|
setPassword(savedPassword);
|
||||||
|
setRememberMe(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -26,7 +40,22 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate("/sight");
|
|
||||||
|
if (rememberMe) {
|
||||||
|
localStorage.setItem("rememberedEmail", email);
|
||||||
|
localStorage.setItem("rememberedPassword", password);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("rememberedEmail");
|
||||||
|
localStorage.removeItem("rememberedPassword");
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate("/map");
|
||||||
|
try {
|
||||||
|
await getUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("Вход в систему выполнен успешно");
|
toast.success("Вход в систему выполнен успешно");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -47,73 +76,102 @@ export const LoginPage = () => {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
minHeight: "100vh",
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
gap: 3,
|
gap: 3,
|
||||||
p: 3,
|
p: 3,
|
||||||
|
backgroundImage: "url('/login-bg.png')",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Paper
|
||||||
Вход в систему
|
elevation={3}
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
p: 4,
|
||||||
flexDirection: "column",
|
borderRadius: 2,
|
||||||
gap: 2,
|
backgroundColor: "white",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "400px",
|
maxWidth: "400px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error && (
|
<Typography
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
variant="h4"
|
||||||
{error}
|
component="h1"
|
||||||
</Alert>
|
className="text-center pb-[50px]"
|
||||||
)}
|
>
|
||||||
<TextField
|
Вход в систему
|
||||||
label="Email"
|
</Typography>
|
||||||
type="email"
|
<Box
|
||||||
variant="outlined"
|
component="form"
|
||||||
fullWidth
|
onSubmit={handleSubmit}
|
||||||
required
|
|
||||||
autoComplete="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
error={!!error}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Пароль"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
error={!!error}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
size="large"
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
|
||||||
height: "50px",
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
gap: 2,
|
||||||
borderRadius: "10px",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
{error && (
|
||||||
</Button>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
</Box>
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Запомнить пароль"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "50px",
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ interface ApiSight {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
|
||||||
|
|
||||||
|
const arePathsEqual = (
|
||||||
|
path1: [number, number][],
|
||||||
|
path2: [number, number][]
|
||||||
|
): boolean => {
|
||||||
|
if (path1.length !== path2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < path1.length; i++) {
|
||||||
|
if (
|
||||||
|
Math.abs(path1[i][0] - path2[i][0]) > COORDINATE_PRECISION_TOLERANCE ||
|
||||||
|
Math.abs(path1[i][1] - path2[i][1]) > COORDINATE_PRECISION_TOLERANCE
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
class MapStore {
|
class MapStore {
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
@@ -32,26 +52,22 @@ class MapStore {
|
|||||||
sights: ApiSight[] = [];
|
sights: ApiSight[] = [];
|
||||||
|
|
||||||
getRoutes = async () => {
|
getRoutes = async () => {
|
||||||
const routes = await languageInstance("ru").get("/route");
|
const response = await languageInstance("ru").get("/route");
|
||||||
|
|
||||||
const routedIds = routes.data.map((route: any) => route.id);
|
const routesIds = response.data.map((route: any) => route.id);
|
||||||
|
for (const id of routesIds) {
|
||||||
const mappedRoutes: ApiRoute[] = [];
|
const route = await languageInstance("ru").get(`/route/${id}`);
|
||||||
for (const routeId of routedIds) {
|
this.routes.push({
|
||||||
const responseSoloRoute = await languageInstance("ru").get(
|
id: route.data.id,
|
||||||
`/route/${routeId}`
|
route_number: route.data.route_number,
|
||||||
);
|
path: route.data.path,
|
||||||
const route = responseSoloRoute.data;
|
});
|
||||||
|
|
||||||
const mappedRoute = {
|
|
||||||
id: route.id,
|
|
||||||
route_number: route.route_number,
|
|
||||||
path: route.path,
|
|
||||||
};
|
|
||||||
|
|
||||||
mappedRoutes.push(mappedRoute);
|
|
||||||
}
|
}
|
||||||
|
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
|
||||||
|
id: route.id,
|
||||||
|
route_number: route.route_number,
|
||||||
|
path: route.path,
|
||||||
|
}));
|
||||||
this.routes = mappedRoutes.sort((a, b) =>
|
this.routes = mappedRoutes.sort((a, b) =>
|
||||||
a.route_number.localeCompare(b.route_number)
|
a.route_number.localeCompare(b.route_number)
|
||||||
);
|
);
|
||||||
@@ -59,27 +75,23 @@ class MapStore {
|
|||||||
|
|
||||||
getStations = async () => {
|
getStations = async () => {
|
||||||
const stations = await languageInstance("ru").get("/station");
|
const stations = await languageInstance("ru").get("/station");
|
||||||
const mappedStations = stations.data.map((station: any) => ({
|
this.stations = stations.data.map((station: any) => ({
|
||||||
id: station.id,
|
id: station.id,
|
||||||
name: station.name,
|
name: station.name,
|
||||||
latitude: station.latitude,
|
latitude: station.latitude,
|
||||||
longitude: station.longitude,
|
longitude: station.longitude,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.stations = mappedStations;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getSights = async () => {
|
getSights = async () => {
|
||||||
const sights = await languageInstance("ru").get("/sight");
|
const sights = await languageInstance("ru").get("/sight");
|
||||||
const mappedSights = sights.data.map((sight: any) => ({
|
this.sights = sights.data.map((sight: any) => ({
|
||||||
id: sight.id,
|
id: sight.id,
|
||||||
name: sight.name,
|
name: sight.name,
|
||||||
description: sight.description,
|
description: sight.description,
|
||||||
latitude: sight.latitude,
|
latitude: sight.latitude,
|
||||||
longitude: sight.longitude,
|
longitude: sight.longitude,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.sights = mappedSights;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteRecourse = async (recourse: string, id: number) => {
|
deleteRecourse = async (recourse: string, id: number) => {
|
||||||
@@ -94,31 +106,94 @@ class MapStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleSave = async (json: string) => {
|
handleSave = async (json: string) => {
|
||||||
const sights: any[] = [];
|
const newSights: any[] = [];
|
||||||
const routes: any[] = [];
|
const newRoutes: any[] = [];
|
||||||
const stations: any[] = [];
|
const newStations: any[] = [];
|
||||||
|
const updatedSights: any[] = [];
|
||||||
|
const updatedRoutes: any[] = [];
|
||||||
|
const updatedStations: any[] = [];
|
||||||
|
|
||||||
const parsedJSON = JSON.parse(json);
|
const parsedJSON = JSON.parse(json);
|
||||||
|
|
||||||
console.log(parsedJSON);
|
for (const feature of parsedJSON.features) {
|
||||||
parsedJSON.features.forEach((feature: any) => {
|
|
||||||
const { geometry, properties, id } = feature;
|
const { geometry, properties, id } = feature;
|
||||||
const idCanBeSplited = id.split("-");
|
const idParts = String(id).split("-");
|
||||||
|
|
||||||
if (!(idCanBeSplited.length > 1)) {
|
if (idParts.length > 1) {
|
||||||
|
const featureType = idParts[0];
|
||||||
|
const numericId = parseInt(idParts[1], 10);
|
||||||
|
if (isNaN(numericId)) continue;
|
||||||
|
|
||||||
|
if (featureType === "station") {
|
||||||
|
const originalStation = this.stations.find((s) => s.id === numericId);
|
||||||
|
if (!originalStation) continue;
|
||||||
|
|
||||||
|
const currentStation = {
|
||||||
|
name: properties.name || "",
|
||||||
|
latitude: geometry.coordinates[1],
|
||||||
|
longitude: geometry.coordinates[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalStation.name !== currentStation.name ||
|
||||||
|
Math.abs(originalStation.latitude - currentStation.latitude) >
|
||||||
|
COORDINATE_PRECISION_TOLERANCE ||
|
||||||
|
Math.abs(originalStation.longitude - currentStation.longitude) >
|
||||||
|
COORDINATE_PRECISION_TOLERANCE
|
||||||
|
) {
|
||||||
|
updatedStations.push({ id: numericId, ...currentStation });
|
||||||
|
}
|
||||||
|
} else if (featureType === "route") {
|
||||||
|
const originalRoute = this.routes.find((r) => r.id === numericId);
|
||||||
|
if (!originalRoute) continue;
|
||||||
|
|
||||||
|
const currentRoute = {
|
||||||
|
route_number: properties.name || "",
|
||||||
|
path: geometry.coordinates,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalRoute.route_number !== currentRoute.route_number ||
|
||||||
|
!arePathsEqual(originalRoute.path, currentRoute.path)
|
||||||
|
) {
|
||||||
|
updatedRoutes.push({ id: numericId, ...currentRoute });
|
||||||
|
}
|
||||||
|
} else if (featureType === "sight") {
|
||||||
|
const originalSight = this.sights.find((s) => s.id === numericId);
|
||||||
|
if (!originalSight) continue;
|
||||||
|
|
||||||
|
const currentSight = {
|
||||||
|
name: properties.name || "",
|
||||||
|
description: properties.description || "",
|
||||||
|
latitude: geometry.coordinates[1],
|
||||||
|
longitude: geometry.coordinates[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalSight.name !== currentSight.name ||
|
||||||
|
originalSight.description !== currentSight.description ||
|
||||||
|
Math.abs(originalSight.latitude - currentSight.latitude) >
|
||||||
|
COORDINATE_PRECISION_TOLERANCE ||
|
||||||
|
Math.abs(originalSight.longitude - currentSight.longitude) >
|
||||||
|
COORDINATE_PRECISION_TOLERANCE
|
||||||
|
) {
|
||||||
|
updatedSights.push({ id: numericId, ...currentSight });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (properties.featureType === "station") {
|
if (properties.featureType === "station") {
|
||||||
stations.push({
|
newStations.push({
|
||||||
name: properties.name || "",
|
name: properties.name || "",
|
||||||
latitude: geometry.coordinates[1],
|
latitude: geometry.coordinates[1],
|
||||||
longitude: geometry.coordinates[0],
|
longitude: geometry.coordinates[0],
|
||||||
});
|
});
|
||||||
} else if (properties.featureType === "route") {
|
} else if (properties.featureType === "route") {
|
||||||
routes.push({
|
newRoutes.push({
|
||||||
route_number: properties.name || "",
|
route_number: properties.name || "",
|
||||||
path: geometry.coordinates,
|
path: geometry.coordinates,
|
||||||
});
|
});
|
||||||
} else if (properties.featureType === "sight") {
|
} else if (properties.featureType === "sight") {
|
||||||
sights.push({
|
newSights.push({
|
||||||
name: properties.name || "",
|
name: properties.name || "",
|
||||||
description: properties.description || "",
|
description: properties.description || "",
|
||||||
latitude: geometry.coordinates[1],
|
latitude: geometry.coordinates[1],
|
||||||
@@ -126,16 +201,45 @@ class MapStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
for (const station of stations) {
|
const requests: Promise<any>[] = [];
|
||||||
await languageInstance("ru").post("/station", station);
|
|
||||||
|
newStations.forEach((data) =>
|
||||||
|
requests.push(languageInstance("ru").post("/station", data))
|
||||||
|
);
|
||||||
|
newRoutes.forEach((data) =>
|
||||||
|
requests.push(languageInstance("ru").post("/route", data))
|
||||||
|
);
|
||||||
|
newSights.forEach((data) =>
|
||||||
|
requests.push(languageInstance("ru").post("/sight", data))
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedStations.forEach(({ id, ...data }) =>
|
||||||
|
requests.push(languageInstance("ru").patch(`/station/${id}`, data))
|
||||||
|
);
|
||||||
|
updatedRoutes.forEach(({ id, ...data }) =>
|
||||||
|
requests.push(languageInstance("ru").patch(`/route/${id}`, data))
|
||||||
|
);
|
||||||
|
updatedSights.forEach(({ id, ...data }) =>
|
||||||
|
requests.push(languageInstance("ru").patch(`/sight/${id}`, data))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requests.length === 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
for (const route of routes) {
|
|
||||||
await languageInstance("ru").post("/route", route);
|
try {
|
||||||
}
|
await Promise.all(requests);
|
||||||
for (const sight of sights) {
|
|
||||||
await languageInstance("ru").post("/sight", sight);
|
await Promise.all([
|
||||||
|
this.getRoutes(),
|
||||||
|
this.getStations(),
|
||||||
|
this.getSights(),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка при сохранении данных:", error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import {
|
|||||||
Snackbar,
|
Snackbar,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Save, ArrowLeft } from "lucide-react";
|
import { Save, ArrowLeft } from "lucide-react";
|
||||||
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
import {
|
||||||
|
authInstance,
|
||||||
|
mediaStore,
|
||||||
|
MEDIA_TYPE_LABELS,
|
||||||
|
languageStore,
|
||||||
|
LoadingSpinner,
|
||||||
|
} from "@shared";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer } from "@widgets";
|
||||||
|
|
||||||
export const MediaEditPage = observer(() => {
|
export const MediaEditPage = observer(() => {
|
||||||
@@ -28,7 +34,7 @@ export const MediaEditPage = observer(() => {
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [newFile, setNewFile] = useState<File | null>(null);
|
const [newFile, setNewFile] = useState<File | null>(null);
|
||||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||||
|
|
||||||
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
|
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
|
||||||
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
||||||
@@ -40,51 +46,36 @@ export const MediaEditPage = observer(() => {
|
|||||||
if (id) {
|
if (id) {
|
||||||
mediaStore.getOneMedia(id);
|
mediaStore.getOneMedia(id);
|
||||||
}
|
}
|
||||||
console.log(newFile);
|
|
||||||
console.log(uploadDialogOpen);
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {}, [newFile, uploadDialogOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (media) {
|
if (media) {
|
||||||
setMediaName(media.media_name);
|
setMediaName(media.media_name);
|
||||||
setMediaFilename(media.filename);
|
setMediaFilename(media.filename);
|
||||||
setMediaType(media.media_type);
|
setMediaType(media.media_type);
|
||||||
|
|
||||||
// Set available media types based on current file extension
|
|
||||||
const extension = media.filename.split(".").pop()?.toLowerCase();
|
const extension = media.filename.split(".").pop()?.toLowerCase();
|
||||||
if (extension) {
|
if (extension) {
|
||||||
if (["glb", "gltf"].includes(extension)) {
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
setAvailableMediaTypes([6]); // 3D model
|
setAvailableMediaTypes([6]);
|
||||||
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
} else if (
|
||||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||||
|
extension
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
setAvailableMediaTypes([2]); // Video
|
setAvailableMediaTypes([2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [media]);
|
}, [media]);
|
||||||
|
|
||||||
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
useEffect(() => {
|
||||||
// e.preventDefault();
|
languageStore.setLanguage("ru");
|
||||||
// e.stopPropagation();
|
}, []);
|
||||||
// setIsDragging(false);
|
|
||||||
|
|
||||||
// const files = Array.from(e.dataTransfer.files);
|
|
||||||
// if (files.length > 0) {
|
|
||||||
// setNewFile(files[0]);
|
|
||||||
// setMediaFilename(files[0].name);
|
|
||||||
// setUploadDialogOpen(true); // Open dialog on file drop
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
// e.preventDefault();
|
|
||||||
// setIsDragging(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleDragLeave = () => {
|
|
||||||
// setIsDragging(false);
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
@@ -93,22 +84,25 @@ export const MediaEditPage = observer(() => {
|
|||||||
setNewFile(file);
|
setNewFile(file);
|
||||||
setMediaFilename(file.name);
|
setMediaFilename(file.name);
|
||||||
|
|
||||||
// Determine media type based on file extension
|
|
||||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
if (extension) {
|
if (extension) {
|
||||||
if (["glb", "gltf"].includes(extension)) {
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
setAvailableMediaTypes([6]); // 3D model
|
setAvailableMediaTypes([6]);
|
||||||
setMediaType(6);
|
setMediaType(6);
|
||||||
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
} else if (
|
||||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||||
setMediaType(1); // Default to Photo
|
extension
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||||
|
setMediaType(1);
|
||||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
setAvailableMediaTypes([2]); // Video
|
setAvailableMediaTypes([2]);
|
||||||
setMediaType(2);
|
setMediaType(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadDialogOpen(true); // Open dialog on file selection
|
setUploadDialogOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,11 +119,6 @@ export const MediaEditPage = observer(() => {
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If a new file was selected, the actual file upload will happen
|
|
||||||
// via the UploadMediaDialog. We just need to make sure the metadata
|
|
||||||
// is updated correctly before or after.
|
|
||||||
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
|
|
||||||
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
handleUploadSuccess();
|
handleUploadSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -140,20 +129,25 @@ export const MediaEditPage = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadSuccess = () => {
|
const handleUploadSuccess = () => {
|
||||||
// After successful upload in the dialog, refresh media data if needed
|
|
||||||
if (id) {
|
if (id) {
|
||||||
mediaStore.getOneMedia(id);
|
mediaStore.getOneMedia(id);
|
||||||
}
|
}
|
||||||
setNewFile(null); // Clear the new file state after successful upload
|
setNewFile(null);
|
||||||
setUploadDialogOpen(false);
|
setUploadDialogOpen(false);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!media && id) {
|
if (!media && id) {
|
||||||
// Only show loading if an ID is present and media is not yet loaded
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center items-center h-screen">
|
<Box
|
||||||
<CircularProgress />
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных медиа..." />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Trash2 } from "lucide-react";
|
import { Eye, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal } from "@widgets";
|
import { DeleteModal } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const MediaListPage = observer(() => {
|
export const MediaListPage = observer(() => {
|
||||||
const { media, getMedia, deleteMedia } = mediaStore;
|
const { media, getMedia, deleteMedia } = mediaStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null);
|
||||||
|
const [ids, setIds] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getMedia();
|
const fetchMedia = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getMedia();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchMedia();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -22,6 +32,17 @@ export const MediaListPage = observer(() => {
|
|||||||
field: "media_name",
|
field: "media_name",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "media_type",
|
field: "media_type",
|
||||||
@@ -30,13 +51,15 @@ export const MediaListPage = observer(() => {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
<p>
|
<div className="w-full h-full flex items-center">
|
||||||
{
|
{params.value ? (
|
||||||
MEDIA_TYPE_LABELS[
|
MEDIA_TYPE_LABELS[
|
||||||
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
|
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
|
||||||
]
|
]
|
||||||
}
|
) : (
|
||||||
</p>
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -46,6 +69,7 @@ export const MediaListPage = observer(() => {
|
|||||||
width: 200,
|
width: 200,
|
||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -76,15 +100,37 @@ export const MediaListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div
|
||||||
<h1 className="text-2xl">Медиа</h1>
|
className="flex justify-end mb-5 duration-300"
|
||||||
<CreateButton label="Создать медиа" path="/media/create" />
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
hideFooterPagination
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids) as string[]);
|
||||||
|
}}
|
||||||
hideFooter
|
hideFooter
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,6 +149,19 @@ export const MediaListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteMedia(id)));
|
||||||
|
getMedia();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,30 +15,32 @@ export const MediaPreviewPage = observer(() => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
|
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||||
<div className="w-full h-full flex justify-center items-center">
|
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||||
<MediaViewer className="w-full h-full" media={oneMedia!} />
|
<div className="flex justify-center items-center max-w-[60%]">
|
||||||
</div>
|
<MediaViewer media={oneMedia!} />
|
||||||
|
|
||||||
{oneMedia && (
|
|
||||||
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
|
||||||
<p className="text-white text-center">
|
|
||||||
Чтобы скачать файл, нажмите на кнопку ниже
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
startIcon={<Download size={16} />}
|
|
||||||
component="a"
|
|
||||||
href={`${
|
|
||||||
import.meta.env.VITE_KRBL_MEDIA
|
|
||||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Скачать
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{oneMedia && (
|
||||||
|
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||||
|
<p className="text-white text-center">
|
||||||
|
Чтобы скачать файл, нажмите на кнопку ниже
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Download size={16} />}
|
||||||
|
component="a"
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_KRBL_MEDIA
|
||||||
|
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
691
src/pages/Route/LinekedStations.tsx
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
FormControl,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
useTheme,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TableBody,
|
||||||
|
IconButton,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Droppable,
|
||||||
|
Draggable,
|
||||||
|
DropResult,
|
||||||
|
} from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatedCircleButton,
|
||||||
|
authInstance,
|
||||||
|
languageStore,
|
||||||
|
routeStore,
|
||||||
|
selectedCityStore,
|
||||||
|
stationsStore,
|
||||||
|
} from "@shared";
|
||||||
|
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||||||
|
|
||||||
|
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||||
|
const index = pos - 1;
|
||||||
|
const result = [...arr];
|
||||||
|
if (index >= result.length) {
|
||||||
|
result.push(value);
|
||||||
|
} else {
|
||||||
|
result.splice(index, 0, value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
||||||
|
const result = Array.from(list);
|
||||||
|
const [removed] = result.splice(startIndex, 1);
|
||||||
|
result.splice(endIndex, 0, removed);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Field<T> = {
|
||||||
|
label: string;
|
||||||
|
data: keyof T;
|
||||||
|
render?: (value: any) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkedItemsProps<T> = {
|
||||||
|
parentId: string | number;
|
||||||
|
fields: Field<T>[];
|
||||||
|
setItemsParent?: (items: T[]) => void;
|
||||||
|
type: "show" | "edit";
|
||||||
|
dragAllowed?: boolean;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
dontRecurse?: boolean;
|
||||||
|
disableCreation?: boolean;
|
||||||
|
updatedLinkedItems?: T[];
|
||||||
|
refresh?: number;
|
||||||
|
routeDirection?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkedItems = <
|
||||||
|
T extends { id: number; name: string; [key: string]: any }
|
||||||
|
>(
|
||||||
|
props: LinkedItemsProps<T>
|
||||||
|
) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Accordion sx={{ width: "100%" }}>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
background: theme.palette.background.paper,
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
Привязанные станции
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
|
||||||
|
<AccordionDetails
|
||||||
|
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||||
|
>
|
||||||
|
<Stack gap={2} width="100%">
|
||||||
|
<LinkedItemsContents {...props} />
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkedItemsContentsInner = <
|
||||||
|
T extends { id: number; name: string; [key: string]: any }
|
||||||
|
>({
|
||||||
|
parentId,
|
||||||
|
setItemsParent,
|
||||||
|
fields,
|
||||||
|
dragAllowed = false,
|
||||||
|
type,
|
||||||
|
onUpdate,
|
||||||
|
disableCreation = false,
|
||||||
|
updatedLinkedItems,
|
||||||
|
refresh,
|
||||||
|
routeDirection,
|
||||||
|
}: LinkedItemsProps<T>) => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const [position, setPosition] = useState<number>(1);
|
||||||
|
const [allItems, setAllItems] = useState<T[]>([]);
|
||||||
|
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
|
||||||
|
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
|
||||||
|
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {}, [error]);
|
||||||
|
|
||||||
|
const parentResource = "route";
|
||||||
|
const childResource = "station";
|
||||||
|
|
||||||
|
const availableItems = allItems
|
||||||
|
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||||
|
.filter((item) => {
|
||||||
|
if (routeDirection === undefined) return true;
|
||||||
|
|
||||||
|
return item.direction === routeDirection;
|
||||||
|
})
|
||||||
|
.filter((item) => {
|
||||||
|
const selectedCityId = selectedCityStore.selectedCityId;
|
||||||
|
if (selectedCityId && "city_id" in item) {
|
||||||
|
return item.city_id === selectedCityId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const filteredAvailableItems = availableItems.filter((item) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updatedLinkedItems) {
|
||||||
|
setLinkedItems(updatedLinkedItems);
|
||||||
|
}
|
||||||
|
}, [updatedLinkedItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItemsParent?.(linkedItems);
|
||||||
|
}, [linkedItems, setItemsParent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPosition(linkedItems.length + 1);
|
||||||
|
}, [linkedItems.length]);
|
||||||
|
|
||||||
|
const getStationTransfers = (stationId: number, fallbackTransfers?: any) => {
|
||||||
|
const { stationLists } = stationsStore;
|
||||||
|
for (const lang of ["ru", "en", "zh"] as const) {
|
||||||
|
const station = stationLists[lang].data.find(
|
||||||
|
(s: any) => s.id === stationId
|
||||||
|
);
|
||||||
|
if (station?.transfers) {
|
||||||
|
return station.transfers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackTransfers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const reorderedItems = reorder(
|
||||||
|
linkedItems,
|
||||||
|
result.source.index,
|
||||||
|
result.destination.index
|
||||||
|
);
|
||||||
|
|
||||||
|
setLinkedItems(reorderedItems);
|
||||||
|
|
||||||
|
authInstance
|
||||||
|
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||||
|
stations: reorderedItems.map((item) => {
|
||||||
|
const stationData: any = { id: item.id };
|
||||||
|
const transfers = getStationTransfers(item.id, item.transfers);
|
||||||
|
if (transfers) {
|
||||||
|
stationData.transfers = transfers;
|
||||||
|
}
|
||||||
|
return stationData;
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error updating station order:", error);
|
||||||
|
setError("Failed to update station order");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentId) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
authInstance
|
||||||
|
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setLinkedItems(response?.data || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching linked items:", error);
|
||||||
|
setError("Failed to load linked stations");
|
||||||
|
setLinkedItems([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [parentId, language, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === "edit") {
|
||||||
|
setError(null);
|
||||||
|
authInstance
|
||||||
|
.get(`/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setAllItems(response?.data || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching all items:", error);
|
||||||
|
setError("Failed to load available stations");
|
||||||
|
setAllItems([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
const linkItem = () => {
|
||||||
|
if (selectedItemId !== null) {
|
||||||
|
setError(null);
|
||||||
|
const selectedItem = allItems.find((item) => item.id === selectedItemId);
|
||||||
|
const requestData = {
|
||||||
|
stations: insertAtPosition(
|
||||||
|
linkedItems.map((item) => {
|
||||||
|
const stationData: any = { id: item.id };
|
||||||
|
const transfers = getStationTransfers(item.id, item.transfers);
|
||||||
|
if (transfers) {
|
||||||
|
stationData.transfers = transfers;
|
||||||
|
}
|
||||||
|
return stationData;
|
||||||
|
}),
|
||||||
|
position,
|
||||||
|
(() => {
|
||||||
|
const newStationData: any = { id: selectedItemId };
|
||||||
|
const transfers = getStationTransfers(
|
||||||
|
selectedItemId,
|
||||||
|
selectedItem?.transfers
|
||||||
|
);
|
||||||
|
if (transfers) {
|
||||||
|
newStationData.transfers = transfers;
|
||||||
|
}
|
||||||
|
return newStationData;
|
||||||
|
})()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsLinkingSingle(true);
|
||||||
|
authInstance
|
||||||
|
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||||
|
.then(() => {
|
||||||
|
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||||
|
if (newItem) {
|
||||||
|
const updatedList = insertAtPosition(
|
||||||
|
[...linkedItems],
|
||||||
|
position,
|
||||||
|
newItem
|
||||||
|
);
|
||||||
|
setLinkedItems(updatedList);
|
||||||
|
}
|
||||||
|
setSelectedItemId(null);
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error linking item:", error);
|
||||||
|
setError("Failed to link station");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLinkingSingle(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (itemId: number) => {
|
||||||
|
setError(null);
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
authInstance
|
||||||
|
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||||
|
data: { [`${childResource}_id`]: itemId },
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error deleting item:", error);
|
||||||
|
setError("Failed to delete station");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStationClick = (item: T) => {
|
||||||
|
routeStore.setSelectedStationId(item.id);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (itemId: number) => {
|
||||||
|
const newSelected = new Set(selectedItems);
|
||||||
|
if (newSelected.has(itemId)) {
|
||||||
|
newSelected.delete(itemId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(itemId);
|
||||||
|
}
|
||||||
|
setSelectedItems(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkLink = () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsLinkingBulk(true);
|
||||||
|
const selectedStations = Array.from(selectedItems).map((id) => {
|
||||||
|
const item = allItems.find((item) => item.id === id);
|
||||||
|
const stationData: any = { id };
|
||||||
|
const transfers = getStationTransfers(id, item?.transfers);
|
||||||
|
if (transfers) {
|
||||||
|
stationData.transfers = transfers;
|
||||||
|
}
|
||||||
|
return stationData;
|
||||||
|
});
|
||||||
|
const requestData = {
|
||||||
|
stations: [
|
||||||
|
...linkedItems.map((item) => {
|
||||||
|
const stationData: any = { id: item.id };
|
||||||
|
const transfers = getStationTransfers(item.id, item.transfers);
|
||||||
|
if (transfers) {
|
||||||
|
stationData.transfers = transfers;
|
||||||
|
}
|
||||||
|
return stationData;
|
||||||
|
}),
|
||||||
|
...selectedStations,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
authInstance
|
||||||
|
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||||
|
.then(() => {
|
||||||
|
const newItems = allItems.filter((item) => selectedItems.has(item.id));
|
||||||
|
setLinkedItems([...linkedItems, ...newItems]);
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error linking items:", error);
|
||||||
|
setError("Failed to link stations");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLinkingBulk(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{linkedItems?.length > 0 && (
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||||
|
<Table sx={{ width: "100%" }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{type === "edit" && dragAllowed && (
|
||||||
|
<TableCell width="40px"></TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell key="id" width="60px">
|
||||||
|
№
|
||||||
|
</TableCell>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<TableCell key={String(field.data)}>
|
||||||
|
{field.label}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell width="120px">Действие</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<Droppable
|
||||||
|
droppableId="droppable-stations"
|
||||||
|
isDropDisabled={type !== "edit" || !dragAllowed}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<TableBody
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
>
|
||||||
|
{linkedItems.map((item, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={item.id}
|
||||||
|
draggableId={"station-" + String(item.id)}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={type !== "edit" || !dragAllowed}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<TableRow
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
hover
|
||||||
|
onClick={() => handleStationClick(item)}
|
||||||
|
>
|
||||||
|
{type === "edit" && dragAllowed && (
|
||||||
|
<TableCell {...provided.dragHandleProps}>
|
||||||
|
<IconButton size="small">
|
||||||
|
<DragIndicatorIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<TableCell key={String(field.data) + String(idx)}>
|
||||||
|
{field.render
|
||||||
|
? field.render(item[field.data])
|
||||||
|
: item[field.data]}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell>
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteItem(item.id);
|
||||||
|
}}
|
||||||
|
disabled={detachingIds.has(item.id)}
|
||||||
|
loading={detachingIds.has(item.id)}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</TableBody>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</DragDropContext>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkedItems.length === 0 && !isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
Станции не найдены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "edit" && !disableCreation && (
|
||||||
|
<Stack gap={2} mt={2}>
|
||||||
|
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||||
|
{routeDirection !== undefined && (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
Показываются только остановки для{" "}
|
||||||
|
{routeDirection ? "прямого" : "обратного"} направления
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||||
|
>
|
||||||
|
<Tab label="По одной" />
|
||||||
|
<Tab label="Массово" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
value={
|
||||||
|
availableItems?.find(
|
||||||
|
(item) => item.id === selectedItemId
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
setSelectedItemId(newValue?.id || null)
|
||||||
|
}
|
||||||
|
options={availableItems}
|
||||||
|
getOptionLabel={(item) => String(item.name)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите остановку"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
option.id === value?.id
|
||||||
|
}
|
||||||
|
filterOptions={(options, { inputValue }) => {
|
||||||
|
const searchWords = inputValue
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean);
|
||||||
|
return options.filter((option) => {
|
||||||
|
const optionWords = String(option.name)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ");
|
||||||
|
return searchWords.every((searchWord) =>
|
||||||
|
optionWords.some((word) => word.startsWith(searchWord))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.id}>
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<p>{String(option.name)}</p>
|
||||||
|
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
|
||||||
|
{String(option.description)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Позиция добавляемой остановки"
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = Math.max(1, Number(e.target.value));
|
||||||
|
setPosition(
|
||||||
|
newValue > linkedItems.length + 1
|
||||||
|
? linkedItems.length + 1
|
||||||
|
: newValue
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
inputProps: { min: 1, max: linkedItems.length + 1 },
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="contained"
|
||||||
|
onClick={linkItem}
|
||||||
|
disabled={!selectedItemId || isLinkingSingle}
|
||||||
|
loading={isLinkingSingle}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<Stack gap={2}>
|
||||||
|
{/* Поле поиска */}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Поиск остановок"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Введите название остановки..."
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Список доступных остановок с чекбоксами */}
|
||||||
|
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||||
|
<Stack gap={1}>
|
||||||
|
{filteredAvailableItems.map((item) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={item.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
onChange={() => handleCheckboxChange(item.id)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<div className="flex justify-between items-center w-full gap-10">
|
||||||
|
<p>{String(item.name)}</p>
|
||||||
|
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
|
||||||
|
{String(item.description)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
margin: 0,
|
||||||
|
"& .MuiFormControlLabel-label": {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredAvailableItems.length === 0 && (
|
||||||
|
<Typography
|
||||||
|
color="textSecondary"
|
||||||
|
textAlign="center"
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
{searchQuery.trim()
|
||||||
|
? "Остановки не найдены"
|
||||||
|
: "Нет доступных остановок"}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleBulkLink}
|
||||||
|
disabled={selectedItems.size === 0 || isLinkingBulk}
|
||||||
|
loading={isLinkingBulk}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Добавить выбранные ({selectedItems.size})
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkedItemsContents = observer(
|
||||||
|
LinkedItemsContentsInner
|
||||||
|
) as typeof LinkedItemsContentsInner;
|
||||||
@@ -8,16 +8,27 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||||
|
import {
|
||||||
|
languageStore,
|
||||||
|
ArticleSelectOrCreateDialog,
|
||||||
|
SelectMediaDialog,
|
||||||
|
selectedCityStore,
|
||||||
|
UploadMediaDialog,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
export const RouteCreatePage = observer(() => {
|
export const RouteCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -27,59 +38,219 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||||
const [direction, setDirection] = useState("backward");
|
const [direction, setDirection] = useState("backward");
|
||||||
const [scaleMin, setScaleMin] = useState("");
|
const [scaleMin, setScaleMin] = useState("10");
|
||||||
const [scaleMax, setScaleMax] = useState("");
|
const [scaleMax, setScaleMax] = useState("100");
|
||||||
|
const [routeName, setRouteName] = useState("");
|
||||||
const [turn, setTurn] = useState("");
|
const [turn, setTurn] = useState("");
|
||||||
const [centerLat, setCenterLat] = useState("");
|
const [centerLat, setCenterLat] = useState("");
|
||||||
const [centerLng, setCenterLng] = useState("");
|
const [centerLng, setCenterLng] = useState("");
|
||||||
|
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||||
|
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||||
|
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||||
|
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
carrierStore.getCarriers();
|
carrierStore.getCarriers(language);
|
||||||
articlesStore.getArticleList();
|
articlesStore.getArticleList();
|
||||||
}, []);
|
}, [language]);
|
||||||
|
|
||||||
|
const filteredCarriers = useMemo(() => {
|
||||||
|
const carriers =
|
||||||
|
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||||||
|
.data || [];
|
||||||
|
|
||||||
|
if (!selectedCityStore.selectedCityId) {
|
||||||
|
return carriers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return carriers.filter(
|
||||||
|
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
|
||||||
|
);
|
||||||
|
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
|
||||||
|
|
||||||
|
const validateCoordinates = (value: string) => {
|
||||||
|
try {
|
||||||
|
const lines = value.trim().split("\n");
|
||||||
|
const coordinates = lines.map((line) => {
|
||||||
|
const [lat, lon] = line
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number);
|
||||||
|
return [lat, lon];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coordinates.length === 0) {
|
||||||
|
return "Введите хотя бы одну пару координат";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!coordinates.every(
|
||||||
|
(point) => Array.isArray(point) && point.length === 2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Каждая строка должна содержать две координаты";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!coordinates.every((point) =>
|
||||||
|
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Координаты должны быть числами";
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return "Неверный формат координат";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArticleSelect = (articleId: number) => {
|
||||||
|
setGovernorAppeal(articleId.toString());
|
||||||
|
setIsSelectArticleDialogOpen(false);
|
||||||
|
articlesStore.getArticleList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setVideoPreview(media.id);
|
||||||
|
setIsSelectVideoDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoFileSelect = (file?: File) => {
|
||||||
|
if (file) {
|
||||||
|
setFileToUpload(file);
|
||||||
|
setIsUploadVideoDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setIsSelectVideoDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoUpload = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setVideoPreview(media.id);
|
||||||
|
setIsUploadVideoDialogOpen(false);
|
||||||
|
setFileToUpload(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoPreviewClick = () => {
|
||||||
|
setIsVideoPreviewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateRoute = async () => {
|
const handleCreateRoute = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Преобразуем значения в нужные типы
|
|
||||||
|
if (!routeName.trim()) {
|
||||||
|
toast.error("Заполните название маршрута");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!carrier) {
|
||||||
|
toast.error("Выберите перевозчика");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!routeNumber.trim()) {
|
||||||
|
toast.error("Заполните номер маршрута");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!govRouteNumber.trim()) {
|
||||||
|
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResult = validateCoordinates(routeCoords);
|
||||||
|
if (validationResult !== true) {
|
||||||
|
toast.error(validationResult);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale_min = scaleMin ? Number(scaleMin) : null;
|
||||||
|
const scale_max = scaleMax ? Number(scaleMax) : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
scale_min === 0 ||
|
||||||
|
scale_max === 0 ||
|
||||||
|
scale_min === null ||
|
||||||
|
scale_max === null
|
||||||
|
) {
|
||||||
|
toast.error("Масштабы не могут быть равны 0");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
scale_min !== null &&
|
||||||
|
scale_max !== null &&
|
||||||
|
scale_max !== undefined &&
|
||||||
|
scale_min > scale_max
|
||||||
|
) {
|
||||||
|
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const carrier_id = Number(carrier);
|
const carrier_id = Number(carrier);
|
||||||
const governor_appeal = Number(governorAppeal);
|
const governor_appeal = governorAppeal
|
||||||
const scale_min = scaleMin ? Number(scaleMin) : undefined;
|
? Number(governorAppeal)
|
||||||
const scale_max = scaleMax ? Number(scaleMax) : undefined;
|
: undefined;
|
||||||
const rotate = turn ? Number(turn) : undefined;
|
const rotate = turn ? Number(turn) : undefined;
|
||||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||||
const route_direction = direction === "forward";
|
const route_direction = direction === "forward";
|
||||||
// Координаты маршрута как массив массивов чисел
|
|
||||||
const path = routeCoords
|
|
||||||
.split("\n")
|
|
||||||
.map((line) =>
|
|
||||||
line
|
|
||||||
.split(" ")
|
|
||||||
.map((coord) => Number(coord.trim()))
|
|
||||||
.filter((n) => !isNaN(n))
|
|
||||||
)
|
|
||||||
.filter((arr) => arr.length === 2);
|
|
||||||
|
|
||||||
// Собираем объект маршрута
|
const path = routeCoords
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
const [lat, lon] = line
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number);
|
||||||
|
return [lat, lon];
|
||||||
|
});
|
||||||
|
|
||||||
const newRoute: Partial<Route> = {
|
const newRoute: Partial<Route> = {
|
||||||
carrier:
|
carrier:
|
||||||
carrierStore.carriers.data.find((c: any) => c.id === carrier_id)
|
carrierStore.carriers[
|
||||||
?.full_name || "",
|
language as keyof typeof carrierStore.carriers
|
||||||
|
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
|
||||||
carrier_id,
|
carrier_id,
|
||||||
route_number: routeNumber,
|
route_number: routeNumber,
|
||||||
route_sys_number: govRouteNumber,
|
route_sys_number: govRouteNumber,
|
||||||
governor_appeal,
|
route_name: routeName,
|
||||||
route_direction,
|
route_direction,
|
||||||
scale_min,
|
scale_min: scale_min !== null ? scale_min : 0,
|
||||||
scale_max,
|
scale_max: scale_max !== null ? scale_max : 0,
|
||||||
rotate,
|
rotate,
|
||||||
center_latitude,
|
center_latitude,
|
||||||
center_longitude,
|
center_longitude,
|
||||||
path,
|
path,
|
||||||
|
video_preview:
|
||||||
|
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (governor_appeal !== undefined) {
|
||||||
|
newRoute.governor_appeal = governor_appeal;
|
||||||
|
}
|
||||||
|
|
||||||
await routeStore.createRoute(newRoute);
|
await routeStore.createRoute(newRoute);
|
||||||
toast.success("Маршрут успешно создан");
|
toast.success("Маршрут успешно создан");
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
@@ -91,137 +262,304 @@ export const RouteCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||||
|
(article) => article.id === Number(governorAppeal)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
Маршруты / Создать
|
Назад
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Typography variant="h5" fontWeight={700}>
|
|
||||||
Создать маршрут
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
</Typography>
|
<Box className="flex flex-col gap-6 w-full">
|
||||||
<Box className="flex flex-col gap-6 w-full">
|
<TextField
|
||||||
<FormControl fullWidth required>
|
className="w-full"
|
||||||
<InputLabel>Выберите перевозчика</InputLabel>
|
label="Название маршрута"
|
||||||
<Select
|
required
|
||||||
value={carrier}
|
value={routeName}
|
||||||
label="Выберите перевозчика"
|
onChange={(e) => setRouteName(e.target.value)}
|
||||||
onChange={(e) => setCarrier(e.target.value as string)}
|
/>
|
||||||
disabled={carrierStore.carriers.data.length === 0}
|
<FormControl fullWidth required>
|
||||||
>
|
<InputLabel>Выберите перевозчика</InputLabel>
|
||||||
<MenuItem value="">Не выбрано</MenuItem>
|
<Select
|
||||||
{carrierStore.carriers.data.map(
|
value={carrier}
|
||||||
(c: (typeof carrierStore.carriers.data)[number]) => (
|
label="Выберите перевозчика"
|
||||||
<MenuItem key={c.id} value={c.id}>
|
onChange={(e) => setCarrier(e.target.value as string)}
|
||||||
{c.full_name}
|
disabled={filteredCarriers.length === 0}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Не выбрано</MenuItem>
|
||||||
|
{filteredCarriers.map((carrier: any) => (
|
||||||
|
<MenuItem key={carrier.id} value={carrier.id}>
|
||||||
|
{carrier.full_name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Номер маршрута"
|
||||||
|
required
|
||||||
|
value={routeNumber}
|
||||||
|
onChange={(e) => setRouteNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Координаты маршрута"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={10}
|
||||||
|
value={routeCoords}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setRouteCoords(newValue);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const lines = routeCoords.split("\n");
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
|
||||||
|
if (lastLine && lastLine.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
const newValue = routeCoords + "\n";
|
||||||
|
setRouteCoords(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={validateCoordinates(routeCoords) !== true}
|
||||||
|
helperText={
|
||||||
|
typeof validateCoordinates(routeCoords) === "string"
|
||||||
|
? validateCoordinates(routeCoords)
|
||||||
|
: "Формат: широта долгота"
|
||||||
|
}
|
||||||
|
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": {
|
||||||
|
maxHeight: "500px",
|
||||||
|
overflow: "auto",
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
lineHeight: "1.2",
|
||||||
|
padding: "8px 12px",
|
||||||
|
},
|
||||||
|
"& .MuiFormHelperText-root": {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "2px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Номер маршрута в Говорящем Городе"
|
||||||
|
required
|
||||||
|
value={govRouteNumber}
|
||||||
|
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Обращение к пассажирам
|
||||||
|
</Typography>
|
||||||
|
<Box className="flex gap-2">
|
||||||
|
<TextField
|
||||||
|
className="flex-1"
|
||||||
|
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||||
|
placeholder="Выберите статью"
|
||||||
|
disabled
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
color: selectedArticle ? "inherit" : "#999",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedArticle && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setGovernorAppeal("")}
|
||||||
|
startIcon={<X size={16} />}
|
||||||
|
sx={{ minWidth: "auto", px: 2 }}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Select>
|
<Button
|
||||||
</FormControl>
|
variant="outlined"
|
||||||
<TextField
|
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||||
className="w-full"
|
startIcon={<Plus size={16} />}
|
||||||
label="Номер маршрута"
|
sx={{ minWidth: "auto", px: 2 }}
|
||||||
required
|
>
|
||||||
value={routeNumber}
|
Выбрать
|
||||||
onChange={(e) => setRouteNumber(e.target.value)}
|
</Button>
|
||||||
/>
|
</Box>
|
||||||
<TextField
|
|
||||||
className="w-full"
|
<VideoPreviewCard
|
||||||
label="Координаты маршрута"
|
title="Видеозаставка"
|
||||||
multiline
|
videoId={videoPreview}
|
||||||
minRows={3}
|
onVideoClick={handleVideoPreviewClick}
|
||||||
value={routeCoords}
|
onDeleteVideoClick={() => {
|
||||||
onChange={(e) => setRouteCoords(e.target.value)}
|
setVideoPreview("");
|
||||||
/>
|
}}
|
||||||
<TextField
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Номер маршрута в Говорящем Городе"
|
/>
|
||||||
required
|
|
||||||
value={govRouteNumber}
|
<FormControl fullWidth required>
|
||||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||||
/>
|
<Select
|
||||||
<FormControl fullWidth required>
|
value={direction}
|
||||||
<InputLabel>Обращение губернатора</InputLabel>
|
label="Прямой/обратный маршрут"
|
||||||
<Select
|
onChange={(e) => setDirection(e.target.value)}
|
||||||
value={governorAppeal}
|
>
|
||||||
label="Обращение губернатора"
|
<MenuItem value="forward">Прямой</MenuItem>
|
||||||
onChange={(e) => setGovernorAppeal(e.target.value as string)}
|
<MenuItem value="backward">Обратный</MenuItem>
|
||||||
disabled={articlesStore.articleList.ru.data.length === 0}
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Масштаб (мин)"
|
||||||
|
type="number"
|
||||||
|
value={scaleMin}
|
||||||
|
onChange={(e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
if (Number(value) > 297) {
|
||||||
|
value = "297";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(value) < 10) {
|
||||||
|
value = "10";
|
||||||
|
}
|
||||||
|
|
||||||
|
setScaleMin(value);
|
||||||
|
if (value && scaleMax && Number(value) > Number(scaleMax)) {
|
||||||
|
setScaleMax(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={
|
||||||
|
scaleMin !== "" &&
|
||||||
|
scaleMax !== "" &&
|
||||||
|
Number(scaleMin) > Number(scaleMax)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
helperText={
|
||||||
|
scaleMin !== "" &&
|
||||||
|
scaleMax !== "" &&
|
||||||
|
Number(scaleMin) > Number(scaleMax)
|
||||||
|
? "Минимальный масштаб не может быть больше максимального"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Масштаб (макс)"
|
||||||
|
type="number"
|
||||||
|
value={scaleMax}
|
||||||
|
required
|
||||||
|
onChange={(e) => {
|
||||||
|
if (Number(e.target.value) > 300) {
|
||||||
|
e.target.value = "300";
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = e.target.value;
|
||||||
|
setScaleMax(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Поворот"
|
||||||
|
value={turn}
|
||||||
|
onChange={(e) => setTurn(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Центр. широта"
|
||||||
|
value={centerLat}
|
||||||
|
onChange={(e) => setCenterLat(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Центр. долгота"
|
||||||
|
value={centerLng}
|
||||||
|
onChange={(e) => setCenterLng(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreateRoute}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<MenuItem value="">Не выбрано</MenuItem>
|
{isLoading ? (
|
||||||
{articlesStore.articleList.ru.data.map(
|
<Loader2 size={20} className="animate-spin" />
|
||||||
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
|
) : (
|
||||||
<MenuItem key={a.id} value={a.id}>
|
"Сохранить"
|
||||||
{a.heading}
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</Select>
|
</Button>
|
||||||
</FormControl>
|
</div>
|
||||||
<FormControl fullWidth required>
|
|
||||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={direction}
|
|
||||||
label="Прямой/обратный маршрут"
|
|
||||||
onChange={(e) => setDirection(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value="forward">Прямой</MenuItem>
|
|
||||||
<MenuItem value="backward">Обратный</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Масштаб (мин)"
|
|
||||||
value={scaleMin}
|
|
||||||
onChange={(e) => setScaleMin(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Масштаб (макс)"
|
|
||||||
value={scaleMax}
|
|
||||||
onChange={(e) => setScaleMax(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Поворот"
|
|
||||||
value={turn}
|
|
||||||
onChange={(e) => setTurn(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Центр. широта"
|
|
||||||
value={centerLat}
|
|
||||||
onChange={(e) => setCenterLat(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Центр. долгота"
|
|
||||||
value={centerLng}
|
|
||||||
onChange={(e) => setCenterLng(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<div className="flex w-full justify-end">
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
className="w-min flex gap-2 items-center"
|
|
||||||
startIcon={<Save size={20} />}
|
|
||||||
onClick={handleCreateRoute}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={20} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Сохранить"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ArticleSelectOrCreateDialog
|
||||||
|
open={isSelectArticleDialogOpen}
|
||||||
|
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||||
|
onSelectArticle={handleArticleSelect}
|
||||||
|
/>
|
||||||
|
<SelectMediaDialog
|
||||||
|
open={isSelectVideoDialogOpen}
|
||||||
|
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||||
|
onSelectMedia={handleVideoSelect}
|
||||||
|
mediaType={2}
|
||||||
|
/>
|
||||||
|
{videoPreview && videoPreview !== "" && (
|
||||||
|
<Dialog
|
||||||
|
open={isVideoPreviewOpen}
|
||||||
|
onClose={() => setIsVideoPreviewOpen(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box className="flex justify-center items-center p-4">
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: videoPreview,
|
||||||
|
media_type: 2,
|
||||||
|
filename: "video_preview",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setIsVideoPreviewOpen(false)}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadVideoDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsUploadVideoDialogOpen(false);
|
||||||
|
setFileToUpload(null);
|
||||||
|
}}
|
||||||
|
hardcodeType="video_preview"
|
||||||
|
contextObjectName={routeName || "Маршрут"}
|
||||||
|
contextType="sight"
|
||||||
|
initialFile={fileToUpload || undefined}
|
||||||
|
afterUpload={handleVideoUpload}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,218 +8,647 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||||
import { routeStore } from "../../../shared/store/RouteStore";
|
import {
|
||||||
|
routeStore,
|
||||||
|
languageStore,
|
||||||
|
ArticleSelectOrCreateDialog,
|
||||||
|
SelectMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
|
LoadingSpinner,
|
||||||
|
} from "@shared";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { stationsStore } from "@shared";
|
||||||
|
import { LinkedItems } from "../LinekedStations";
|
||||||
|
|
||||||
export const RouteEditPage = observer(() => {
|
export const RouteEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editRouteData } = routeStore;
|
const { editRouteData, copyRouteAction } = routeStore;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||||
|
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||||
|
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||||
|
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||||
|
const { language } = languageStore;
|
||||||
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const response = await routeStore.getRoute(Number(id));
|
if (!id) {
|
||||||
routeStore.setEditRouteData(response);
|
setIsLoadingData(false);
|
||||||
carrierStore.getCarriers();
|
return;
|
||||||
articlesStore.getArticleList();
|
}
|
||||||
|
setIsLoadingData(true);
|
||||||
|
try {
|
||||||
|
const response = await routeStore.getRoute(Number(id));
|
||||||
|
routeStore.setEditRouteData(response);
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await carrierStore.getCarriers(language);
|
||||||
|
await stationsStore.getStations();
|
||||||
|
await articlesStore.getArticleList();
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [id, language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editRouteData.path && editRouteData.path.length > 0) {
|
||||||
|
const formattedPath = editRouteData.path
|
||||||
|
.map((coords) => coords.join(" "))
|
||||||
|
.join("\n");
|
||||||
|
setCoordinates(formattedPath);
|
||||||
|
}
|
||||||
|
}, [editRouteData.path]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
// Валидация обязательных полей
|
||||||
|
if (!editRouteData.route_name?.trim()) {
|
||||||
|
toast.error("Заполните название маршрута");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editRouteData.carrier_id) {
|
||||||
|
toast.error("Выберите перевозчика");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editRouteData.route_number?.trim()) {
|
||||||
|
toast.error("Заполните номер маршрута");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editRouteData.route_sys_number?.trim()) {
|
||||||
|
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResult = validateCoordinates(coordinates);
|
||||||
|
if (validationResult !== true) {
|
||||||
|
toast.error(validationResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация масштабов
|
||||||
|
if (
|
||||||
|
editRouteData.scale_min !== null &&
|
||||||
|
editRouteData.scale_min !== undefined &&
|
||||||
|
editRouteData.scale_max !== null &&
|
||||||
|
editRouteData.scale_max !== undefined &&
|
||||||
|
editRouteData.scale_min > editRouteData.scale_max
|
||||||
|
) {
|
||||||
|
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
editRouteData.scale_min === 0 ||
|
||||||
|
editRouteData.scale_max === 0 ||
|
||||||
|
editRouteData.scale_min === null ||
|
||||||
|
editRouteData.scale_max === null
|
||||||
|
) {
|
||||||
|
toast.error("Масштабы не могут быть равны 0");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await routeStore.editRoute(Number(id));
|
try {
|
||||||
toast.success("Маршрут успешно сохранен");
|
await routeStore.editRoute(Number(id));
|
||||||
setIsLoading(false);
|
toast.success("Маршрут успешно сохранен");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Произошла ошибка при сохранении маршрута");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateCoordinates = (value: string) => {
|
||||||
|
try {
|
||||||
|
const lines = value.trim().split("\n");
|
||||||
|
const coordinates = lines.map((line) => {
|
||||||
|
const [lat, lon] = line
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number);
|
||||||
|
return [lat, lon];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coordinates.length === 0) {
|
||||||
|
return "Введите хотя бы одну пару координат";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!coordinates.every(
|
||||||
|
(point) => Array.isArray(point) && point.length === 2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Каждая строка должна содержать две координаты";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!coordinates.every((point) =>
|
||||||
|
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Координаты должны быть числами";
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return "Неверный формат координат";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await copyRouteAction(Number(id));
|
||||||
|
toast.success("Маршрут успешно скопирован");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArticleSelect = (articleId: number) => {
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
governor_appeal: articleId,
|
||||||
|
});
|
||||||
|
setIsSelectArticleDialogOpen(false);
|
||||||
|
// Обновляем список статей после создания новой
|
||||||
|
articlesStore.getArticleList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
video_preview: media.id,
|
||||||
|
});
|
||||||
|
setIsSelectVideoDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoFileSelect = (file?: File) => {
|
||||||
|
if (file) {
|
||||||
|
setFileToUpload(file);
|
||||||
|
setIsUploadVideoDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setIsSelectVideoDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoUpload = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
video_preview: media.id,
|
||||||
|
});
|
||||||
|
setIsUploadVideoDialogOpen(false);
|
||||||
|
setFileToUpload(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoPreviewClick = () => {
|
||||||
|
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||||
|
setIsVideoPreviewOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получаем название выбранной статьи для отображения
|
||||||
|
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||||
|
(article) => article.id === editRouteData.governor_appeal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных маршрута..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
Маршруты / Редактировать
|
Назад
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Typography variant="h5" fontWeight={700}>
|
|
||||||
Редактировать маршрут
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
</Typography>
|
<Box className="flex flex-col gap-6 w-full">
|
||||||
<Box className="flex flex-col gap-6 w-full">
|
<TextField
|
||||||
<FormControl fullWidth required>
|
className="w-full"
|
||||||
<InputLabel>Выберите перевозчика</InputLabel>
|
label="Название маршрута"
|
||||||
<Select
|
required
|
||||||
value={editRouteData.carrier_id}
|
value={editRouteData.route_name || ""}
|
||||||
label="Выберите перевозчика"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
routeStore.setEditRouteData({
|
routeStore.setEditRouteData({
|
||||||
carrier_id: Number(e.target.value),
|
route_name: e.target.value,
|
||||||
carrier:
|
|
||||||
carrierStore.carriers.data.find(
|
|
||||||
(c) => c.id === Number(e.target.value)
|
|
||||||
)?.full_name || "",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={carrierStore.carriers.data.length === 0}
|
/>
|
||||||
>
|
<FormControl fullWidth required>
|
||||||
<MenuItem value="">Не выбрано</MenuItem>
|
<InputLabel>Выберите перевозчика</InputLabel>
|
||||||
{carrierStore.carriers.data.map(
|
<Select
|
||||||
(c: (typeof carrierStore.carriers.data)[number]) => (
|
value={editRouteData.carrier_id}
|
||||||
<MenuItem key={c.id} value={c.id}>
|
label="Выберите перевозчика"
|
||||||
{c.full_name}
|
onChange={(e) =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
carrier_id: Number(e.target.value),
|
||||||
|
carrier:
|
||||||
|
carrierStore.carriers[
|
||||||
|
language as keyof typeof carrierStore.carriers
|
||||||
|
].data?.find((c) => c.id === Number(e.target.value))
|
||||||
|
?.full_name || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
carrierStore.carriers[
|
||||||
|
language as keyof typeof carrierStore.carriers
|
||||||
|
].data?.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Не выбрано</MenuItem>
|
||||||
|
{carrierStore.carriers[
|
||||||
|
language as keyof typeof carrierStore.carriers
|
||||||
|
].data?.map((carrier) => (
|
||||||
|
<MenuItem key={carrier.id} value={carrier.id}>
|
||||||
|
{carrier.full_name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
))}
|
||||||
)}
|
</Select>
|
||||||
</Select>
|
</FormControl>
|
||||||
</FormControl>
|
<TextField
|
||||||
<TextField
|
className="w-full"
|
||||||
className="w-full"
|
label="Номер маршрута"
|
||||||
label="Номер маршрута"
|
required
|
||||||
required
|
value={editRouteData.route_number || ""}
|
||||||
value={editRouteData.route_number || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
route_number: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Координаты маршрута"
|
|
||||||
multiline
|
|
||||||
minRows={3}
|
|
||||||
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
path: e.target.value
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.split(" ").map(Number)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Номер маршрута в Говорящем Городе"
|
|
||||||
required
|
|
||||||
value={editRouteData.route_sys_number || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
route_sys_number: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth required>
|
|
||||||
<InputLabel>Обращение губернатора</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={editRouteData.governor_appeal || ""}
|
|
||||||
label="Обращение губернатора"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
routeStore.setEditRouteData({
|
routeStore.setEditRouteData({
|
||||||
governor_appeal: Number(e.target.value),
|
route_number: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={articlesStore.articleList.ru.data.length === 0}
|
/>
|
||||||
>
|
<TextField
|
||||||
<MenuItem value="">Не выбрано</MenuItem>
|
className="w-full"
|
||||||
{articlesStore.articleList.ru.data.map(
|
label="Координаты маршрута"
|
||||||
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
|
multiline
|
||||||
<MenuItem key={a.id} value={a.id}>
|
minRows={2}
|
||||||
{a.heading}
|
maxRows={10}
|
||||||
</MenuItem>
|
value={coordinates}
|
||||||
)
|
onChange={(e) => {
|
||||||
)}
|
const newValue = e.target.value;
|
||||||
</Select>
|
setCoordinates(newValue);
|
||||||
</FormControl>
|
|
||||||
<FormControl fullWidth required>
|
const validationResult = validateCoordinates(newValue);
|
||||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
if (validationResult === true) {
|
||||||
<Select
|
const lines = newValue.trim().split("\n");
|
||||||
value={editRouteData.route_direction ? "forward" : "backward"}
|
const path = lines.map((line) => {
|
||||||
label="Прямой/обратный маршрут"
|
const [lat, lon] = line
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number);
|
||||||
|
return [lat, lon];
|
||||||
|
});
|
||||||
|
routeStore.setEditRouteData({ path });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const lines = coordinates.split("\n");
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
|
||||||
|
if (lastLine && lastLine.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
const newValue = coordinates + "\n";
|
||||||
|
setCoordinates(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={validateCoordinates(coordinates) !== true}
|
||||||
|
helperText={
|
||||||
|
typeof validateCoordinates(coordinates) === "string"
|
||||||
|
? validateCoordinates(coordinates)
|
||||||
|
: "Формат: широта долгота"
|
||||||
|
}
|
||||||
|
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": {
|
||||||
|
maxHeight: "500px",
|
||||||
|
overflow: "auto",
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
lineHeight: "1.2",
|
||||||
|
padding: "8px 12px",
|
||||||
|
},
|
||||||
|
"& .MuiFormHelperText-root": {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "2px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Номер маршрута в Говорящем Городе"
|
||||||
|
required
|
||||||
|
value={editRouteData.route_sys_number || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
routeStore.setEditRouteData({
|
routeStore.setEditRouteData({
|
||||||
route_direction: e.target.value === "forward",
|
route_sys_number: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={editRouteData.route_direction ? "forward" : "backward"}
|
||||||
|
label="Прямой/обратный маршрут"
|
||||||
|
onChange={(e) =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
route_direction: e.target.value === "forward",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="forward">Прямой</MenuItem>
|
||||||
|
<MenuItem value="backward">Обратный</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Масштаб (мин)"
|
||||||
|
type="number"
|
||||||
|
value={editRouteData.scale_min ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
let value = e.target.value === "" ? null : e.target.value;
|
||||||
|
|
||||||
|
if (value && Number(value) > 297) {
|
||||||
|
value = "297";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && Number(value) < 10) {
|
||||||
|
value = "10";
|
||||||
|
}
|
||||||
|
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
scale_min: value ? Number(value) : null,
|
||||||
|
});
|
||||||
|
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
editRouteData.scale_max !== null &&
|
||||||
|
editRouteData.scale_max !== undefined &&
|
||||||
|
value &&
|
||||||
|
Number(value) > (editRouteData.scale_max ?? 0)
|
||||||
|
) {
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
scale_max: value ? Number(value) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
required
|
||||||
|
label="Масштаб (макс)"
|
||||||
|
type="number"
|
||||||
|
value={editRouteData.scale_max ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
|
||||||
|
if (Number(value) > 300) {
|
||||||
|
value = "300";
|
||||||
|
}
|
||||||
|
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
scale_max: value === "" ? null : parseFloat(value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
error={
|
||||||
|
editRouteData.scale_min !== null &&
|
||||||
|
editRouteData.scale_min !== undefined &&
|
||||||
|
editRouteData.scale_max !== null &&
|
||||||
|
editRouteData.scale_max !== undefined &&
|
||||||
|
editRouteData.scale_max < editRouteData.scale_min
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
editRouteData.scale_min !== null &&
|
||||||
|
editRouteData.scale_min !== undefined &&
|
||||||
|
editRouteData.scale_max !== null &&
|
||||||
|
editRouteData.scale_max !== undefined &&
|
||||||
|
editRouteData.scale_max < editRouteData.scale_min
|
||||||
|
? "Максимальный масштаб не может быть меньше минимального"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Поворот"
|
||||||
|
value={editRouteData.rotate ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
rotate:
|
||||||
|
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Центр. широта"
|
||||||
|
value={editRouteData.center_latitude ?? ""}
|
||||||
|
type="text"
|
||||||
|
onChange={(e) =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
center_latitude: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Центр. долгота"
|
||||||
|
value={editRouteData.center_longitude ?? ""}
|
||||||
|
type="text"
|
||||||
|
onChange={(e) =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
center_longitude: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Обращение к пассажирам
|
||||||
|
</Typography>
|
||||||
|
<Box className="flex gap-2">
|
||||||
|
<TextField
|
||||||
|
className="flex-1"
|
||||||
|
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||||
|
placeholder="Выберите статью"
|
||||||
|
disabled
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
color: selectedArticle ? "inherit" : "#999",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedArticle && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() =>
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
governor_appeal: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
startIcon={<X size={16} />}
|
||||||
|
sx={{ minWidth: "auto", px: 2 }}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||||
|
startIcon={<Plus size={16} />}
|
||||||
|
sx={{ minWidth: "auto", px: 2 }}
|
||||||
|
>
|
||||||
|
Выбрать
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<VideoPreviewCard
|
||||||
|
title="Видеозаставка"
|
||||||
|
videoId={editRouteData.video_preview}
|
||||||
|
onVideoClick={handleVideoPreviewClick}
|
||||||
|
onDeleteVideoClick={() => {
|
||||||
|
routeStore.setEditRouteData({ video_preview: "" });
|
||||||
|
}}
|
||||||
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<LinkedItems
|
||||||
|
parentId={id || ""}
|
||||||
|
type="edit"
|
||||||
|
dragAllowed={true}
|
||||||
|
fields={[
|
||||||
|
{ label: "Название", data: "name" },
|
||||||
|
{ label: "Описание", data: "description" },
|
||||||
|
]}
|
||||||
|
onUpdate={() => {
|
||||||
|
routeStore.getRoute(Number(id));
|
||||||
|
}}
|
||||||
|
routeDirection={editRouteData.route_direction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Copy size={20} />}
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<MenuItem value="forward">Прямой</MenuItem>
|
Скопировать
|
||||||
<MenuItem value="backward">Обратный</MenuItem>
|
</Button>
|
||||||
</Select>
|
|
||||||
</FormControl>
|
<Button
|
||||||
<TextField
|
variant="contained"
|
||||||
className="w-full"
|
color="primary"
|
||||||
label="Масштаб (мин)"
|
className="w-min flex gap-2 items-center"
|
||||||
value={editRouteData.scale_min || ""}
|
startIcon={<Save size={20} />}
|
||||||
onChange={(e) =>
|
onClick={handleSave}
|
||||||
routeStore.setEditRouteData({
|
disabled={isLoading}
|
||||||
scale_min: Number(e.target.value),
|
>
|
||||||
})
|
Сохранить
|
||||||
}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Масштаб (макс)"
|
|
||||||
value={editRouteData.scale_max || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
scale_max: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Поворот"
|
|
||||||
value={editRouteData.rotate || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
rotate: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Центр. широта"
|
|
||||||
value={editRouteData.center_latitude || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
center_latitude: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Центр. долгота"
|
|
||||||
value={editRouteData.center_longitude || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
center_longitude: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<div className="flex w-full justify-end">
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
className="w-min flex gap-2 items-center"
|
|
||||||
startIcon={<Save size={20} />}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ArticleSelectOrCreateDialog
|
||||||
|
open={isSelectArticleDialogOpen}
|
||||||
|
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||||
|
onSelectArticle={handleArticleSelect}
|
||||||
|
/>
|
||||||
|
<SelectMediaDialog
|
||||||
|
open={isSelectVideoDialogOpen}
|
||||||
|
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||||
|
onSelectMedia={handleVideoSelect}
|
||||||
|
mediaType={2}
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={isVideoPreviewOpen}
|
||||||
|
onClose={() => setIsVideoPreviewOpen(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box className="flex justify-center items-center p-4">
|
||||||
|
{editRouteData.video_preview && (
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: editRouteData.video_preview,
|
||||||
|
media_type: 2,
|
||||||
|
filename: "video_preview",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadVideoDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsUploadVideoDialogOpen(false);
|
||||||
|
setFileToUpload(null);
|
||||||
|
}}
|
||||||
|
hardcodeType="video_preview"
|
||||||
|
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||||
|
contextType="sight"
|
||||||
|
initialFile={fileToUpload || undefined}
|
||||||
|
afterUpload={handleVideoUpload}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,86 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { languageStore, routeStore } from "@shared";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
|
import { carrierStore, languageStore, routeStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Map, Pencil, Trash2 } from "lucide-react";
|
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal } from "@widgets";
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const RouteListPage = observer(() => {
|
export const RouteListPage = observer(() => {
|
||||||
const { routes, getRoutes, deleteRoute } = routeStore;
|
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||||
|
const { carriers, getCarriers } = carrierStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getRoutes();
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getCarriers("ru");
|
||||||
|
await getCarriers("en");
|
||||||
|
await getCarriers("zh");
|
||||||
|
await getRoutes();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
field: "carrier",
|
field: "carrier_id",
|
||||||
headerName: "Перевозчик",
|
headerName: "Перевозчик",
|
||||||
width: 250,
|
width: 250,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
carriers[language].data.find(
|
||||||
|
(carrier) => carrier.id == params.value
|
||||||
|
)?.short_name
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "route_name",
|
||||||
|
headerName: "Название маршрута",
|
||||||
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "route_number",
|
field: "route_number",
|
||||||
headerName: "Номер маршрута",
|
headerName: "Номер маршрута",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "route_direction",
|
field: "route_direction",
|
||||||
@@ -52,6 +106,7 @@ export const RouteListPage = observer(() => {
|
|||||||
width: 250,
|
width: 250,
|
||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
@@ -61,9 +116,7 @@ export const RouteListPage = observer(() => {
|
|||||||
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||||
<Map size={20} className="text-purple-500" />
|
<Map size={20} className="text-purple-500" />
|
||||||
</button>
|
</button>
|
||||||
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
|
|
||||||
<Eye size={20} className="text-green-500" />
|
|
||||||
</button> */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -80,23 +133,52 @@ export const RouteListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = routes.data.map((route) => ({
|
const rows = routes.data.map((route) => ({
|
||||||
id: route.id,
|
id: route.id,
|
||||||
carrier: route.carrier,
|
carrier_id: route.carrier_id,
|
||||||
route_number: route.route_number,
|
route_number: route.route_number,
|
||||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||||
|
route_name: route.route_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<div style={{ width: "100%" }}>
|
<div style={{ width: "100%" }}>
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Маршруты</h1>
|
<h1 className="text-2xl">Маршруты</h1>
|
||||||
<CreateButton label="Создать маршрут" path="/route/create" />
|
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
|
||||||
hideFooter
|
hideFooter
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||||
|
}}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,6 +196,19 @@ export const RouteListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteRoute(id)));
|
||||||
|
getRoutes();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const UP_SCALE = 30000;
|
export const UP_SCALE = 10000;
|
||||||
export const PATH_WIDTH = 15;
|
export const PATH_WIDTH = 5;
|
||||||
export const STATION_RADIUS = 20;
|
export const STATION_RADIUS = 8;
|
||||||
export const STATION_OUTLINE_WIDTH = 10;
|
export const STATION_OUTLINE_WIDTH = 4;
|
||||||
export const SIGHT_SIZE = 60;
|
export const SIGHT_SIZE = 40;
|
||||||
export const SCALE_FACTOR = 50;
|
export const SCALE_FACTOR = 50;
|
||||||
|
|
||||||
export const BACKGROUND_COLOR = 0x111111;
|
export const BACKGROUND_COLOR = 0x000;
|
||||||
export const PATH_COLOR = 0xff4d4d;
|
export const PATH_COLOR = 0xff4d4d;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function InfiniteCanvas({
|
|||||||
setScreenCenter,
|
setScreenCenter,
|
||||||
screenCenter,
|
screenCenter,
|
||||||
} = useTransform();
|
} = useTransform();
|
||||||
const { routeData, originalRouteData } = useMapData();
|
const { routeData, originalRouteData, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
const applicationRef = useApplication();
|
const applicationRef = useApplication();
|
||||||
|
|
||||||
@@ -45,28 +45,28 @@ export function InfiniteCanvas({
|
|||||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||||
const [startRotation, setStartRotation] = useState(0);
|
const [startRotation, setStartRotation] = useState(0);
|
||||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
|
||||||
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
|
|
||||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
|
|
||||||
// Реф для отслеживания последнего значения originalRouteData?.rotate
|
|
||||||
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = applicationRef?.app.canvas;
|
if (!applicationRef?.app?.canvas) return;
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
const canvasRect = canvas.getBoundingClientRect();
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
const canvasLeft = canvasRect.left;
|
const canvasLeft = canvasRect.left;
|
||||||
const canvasTop = canvasRect.top;
|
const canvasTop = canvasRect.top;
|
||||||
const centerX = window.innerWidth / 2 - canvasLeft;
|
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||||
const centerY = window.innerHeight / 2 - canvasTop;
|
const centerY = window.innerHeight / 2 - canvasTop;
|
||||||
setScreenCenter({ x: centerX, y: centerY });
|
setScreenCenter({ x: centerX, y: centerY });
|
||||||
}, [applicationRef?.app.canvas, setScreenCenter]);
|
}, [applicationRef?.app, setScreenCenter]);
|
||||||
|
|
||||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
setIsDragging(true);
|
setIsPointerDown(true);
|
||||||
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
|
setIsDragging(false);
|
||||||
|
setIsUserInteracting(true);
|
||||||
setStartPosition({
|
setStartPosition({
|
||||||
x: position.x,
|
x: position.x,
|
||||||
y: position.y,
|
y: position.y,
|
||||||
@@ -79,13 +79,9 @@ export function InfiniteCanvas({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newRotation = originalRouteData?.rotate ?? 0;
|
const newRotation = originalRouteData?.rotate ?? 0;
|
||||||
|
|
||||||
// Обновляем rotation только если:
|
|
||||||
// 1. Пользователь не взаимодействует с канвасом
|
|
||||||
// 2. Значение действительно изменилось
|
|
||||||
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
||||||
setRotation((newRotation * Math.PI) / 180);
|
setRotation((newRotation * Math.PI) / 180);
|
||||||
lastOriginalRotation.current = newRotation;
|
lastOriginalRotation.current = newRotation;
|
||||||
@@ -93,7 +89,17 @@ export function InfiniteCanvas({
|
|||||||
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
||||||
|
|
||||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
if (!isDragging) return;
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
const dx = e.globalX - startMousePosition.x;
|
||||||
|
const dy = e.globalY - startMousePosition.y;
|
||||||
|
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||||
|
setIsDragging(true);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
const center = screenCenter ?? { x: 0, y: 0 };
|
const center = screenCenter ?? { x: 0, y: 0 };
|
||||||
@@ -106,10 +112,8 @@ export function InfiniteCanvas({
|
|||||||
e.globalX - center.x
|
e.globalX - center.x
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate rotation difference in radians
|
|
||||||
const rotationDiff = currentAngle - startAngle;
|
const rotationDiff = currentAngle - startAngle;
|
||||||
|
|
||||||
// Update rotation
|
|
||||||
setRotation(startRotation + rotationDiff);
|
setRotation(startRotation + rotationDiff);
|
||||||
|
|
||||||
const cosDelta = Math.cos(rotationDiff);
|
const cosDelta = Math.cos(rotationDiff);
|
||||||
@@ -136,9 +140,13 @@ export function InfiniteCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectedSight(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPointerDown(false);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
// Сбрасываем флаг взаимодействия через небольшую задержку
|
|
||||||
// чтобы избежать немедленного срабатывания useEffect
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsUserInteracting(false);
|
setIsUserInteracting(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -147,29 +155,25 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
const handleWheel = (e: FederatedWheelEvent) => {
|
const handleWheel = (e: FederatedWheelEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsUserInteracting(true); // Устанавливаем флаг при зуме
|
setIsUserInteracting(true);
|
||||||
|
|
||||||
// Get mouse position relative to canvas
|
|
||||||
const mouseX = e.globalX - position.x;
|
const mouseX = e.globalX - position.x;
|
||||||
const mouseY = e.globalY - position.y;
|
const mouseY = e.globalY - position.y;
|
||||||
|
|
||||||
// Calculate new scale
|
|
||||||
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
||||||
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
|
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
|
||||||
|
|
||||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
|
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
|
||||||
const actualZoomFactor = newScale / scale;
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
if (scale === newScale) {
|
if (scale === newScale) {
|
||||||
// Сбрасываем флаг, если зум не изменился
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsUserInteracting(false);
|
setIsUserInteracting(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update position to zoom towards mouse cursor
|
|
||||||
setPosition({
|
setPosition({
|
||||||
x: position.x + mouseX * (1 - actualZoomFactor),
|
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||||
y: position.y + mouseY * (1 - actualZoomFactor),
|
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||||
@@ -177,7 +181,6 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
|
|
||||||
// Сбрасываем флаг взаимодействия через задержку
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsUserInteracting(false);
|
setIsUserInteracting(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -185,7 +188,6 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applicationRef?.app.render();
|
applicationRef?.app.render();
|
||||||
console.log(position, scale, rotation);
|
|
||||||
}, [position, scale, rotation]);
|
}, [position, scale, rotation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
import { Stack, Typography, Button } from "@mui/material";
|
import { Box, Stack, Typography, Button } from "@mui/material";
|
||||||
|
|
||||||
import { useNavigate, useNavigationType } from "react-router";
|
import { useNavigate, useNavigationType } from "react-router";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { authInstance } from "@shared";
|
||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import LanguageSelector from "./web-gl/LanguageSelector";
|
||||||
|
|
||||||
export function LeftSidebar() {
|
type LeftSidebarProps = {
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||||
|
const { routeData } = useMapData();
|
||||||
|
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||||
|
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCarrierThumbnail() {
|
||||||
|
if (routeData?.carrier_id) {
|
||||||
|
const { city_id, logo } = (
|
||||||
|
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||||
|
).data;
|
||||||
|
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||||
|
setCarrierThumbnail(arms);
|
||||||
|
setCarrierLogo(logo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCarrierThumbnail();
|
||||||
|
}, [routeData?.carrier_id]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (navigationType === "PUSH") {
|
if (navigationType === "PUSH") {
|
||||||
@@ -15,75 +42,131 @@ export function LeftSidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
|
<Box
|
||||||
<button
|
sx={{
|
||||||
onClick={handleBack}
|
position: "relative",
|
||||||
type="button"
|
height: "100%",
|
||||||
style={{
|
color: "#fff",
|
||||||
display: "flex",
|
transition: "padding 0.3s ease",
|
||||||
justifyContent: "center",
|
p: open ? 2 : 0,
|
||||||
alignItems: "center",
|
display: "flex",
|
||||||
gap: 10,
|
flexDirection: "column",
|
||||||
color: "#fff",
|
alignItems: "stretch",
|
||||||
backgroundColor: "#222",
|
justifyContent: "flex-start",
|
||||||
borderRadius: 10,
|
}}
|
||||||
width: "100%",
|
>
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p>Назад</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="column"
|
direction="column"
|
||||||
alignItems="center"
|
height="100%"
|
||||||
justifyContent="center"
|
width="100%"
|
||||||
my={10}
|
spacing={4}
|
||||||
|
alignItems="stretch"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{
|
||||||
|
opacity: open ? 1 : 0,
|
||||||
|
transition: "opacity 0.25s ease",
|
||||||
|
pointerEvents: open ? "auto" : "none",
|
||||||
|
display: open ? "flex" : "none",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
|
<div>
|
||||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
<Button
|
||||||
При поддержке Правительства Санкт-Петербурга
|
onClick={handleBack}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#222",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
marginBottom: 10,
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#2d2d2d",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
spacing={3}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{carrierThumbnail && (
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: carrierThumbnail,
|
||||||
|
media_type: 1, // Тип "Фото" для логотипа
|
||||||
|
filename: "route_thumbnail",
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
fullHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ color: "#fff" }} textAlign="center">
|
||||||
|
При поддержке Правительства
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 mt-10">
|
||||||
|
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
|
||||||
|
Обращение губернатора
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||||
|
Достопримечательности
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||||
|
Остановки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="column"
|
||||||
|
alignItems="center"
|
||||||
|
maxHeight={150}
|
||||||
|
justifyContent="center"
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
{carrierLogo && (
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: carrierLogo,
|
||||||
|
media_type: 1, // Тип "Фото" для логотипа
|
||||||
|
filename: "route_thumbnail_logo",
|
||||||
|
}}
|
||||||
|
fullHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
textAlign="center"
|
||||||
|
sx={{ color: "#fff", marginTop: "auto" }}
|
||||||
|
>
|
||||||
|
#ВсемПоПути
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack
|
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||||
direction="column"
|
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||||
alignItems="center"
|
</div>
|
||||||
justifyContent="center"
|
</Box>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -29,15 +29,20 @@ const MapDataContext = createContext<{
|
|||||||
isRouteLoading: boolean;
|
isRouteLoading: boolean;
|
||||||
isStationLoading: boolean;
|
isStationLoading: boolean;
|
||||||
isSightLoading: boolean;
|
isSightLoading: boolean;
|
||||||
|
selectedSight?: SightData;
|
||||||
|
setSelectedSight: (sight?: SightData) => void;
|
||||||
setScaleRange: (min: number, max: number) => void;
|
setScaleRange: (min: number, max: number) => void;
|
||||||
setMapRotation: (rotation: number) => void;
|
setMapRotation: (rotation: number) => void;
|
||||||
setMapCenter: (x: number, y: number) => void;
|
setMapCenter: (x: number, y: number) => void;
|
||||||
setStationOffset: (stationId: number, x: number, y: number) => void;
|
setStationOffset: (stationId: number, x: number, y: number) => void;
|
||||||
|
setStationAlign: (stationId: number, align: number) => void;
|
||||||
setSightCoordinates: (
|
setSightCoordinates: (
|
||||||
sightId: number,
|
sightId: number,
|
||||||
latitude: number,
|
latitude: number,
|
||||||
longitude: number
|
longitude: number
|
||||||
) => void;
|
) => void;
|
||||||
|
setIconSize: (size: number) => void;
|
||||||
|
setFontSize: (size: number) => void;
|
||||||
saveChanges: () => void;
|
saveChanges: () => void;
|
||||||
}>({
|
}>({
|
||||||
originalRouteData: undefined,
|
originalRouteData: undefined,
|
||||||
@@ -50,11 +55,16 @@ const MapDataContext = createContext<{
|
|||||||
isRouteLoading: true,
|
isRouteLoading: true,
|
||||||
isStationLoading: true,
|
isStationLoading: true,
|
||||||
isSightLoading: true,
|
isSightLoading: true,
|
||||||
|
selectedSight: undefined,
|
||||||
|
setSelectedSight: () => {},
|
||||||
setScaleRange: () => {},
|
setScaleRange: () => {},
|
||||||
setMapRotation: () => {},
|
setMapRotation: () => {},
|
||||||
setMapCenter: () => {},
|
setMapCenter: () => {},
|
||||||
setStationOffset: () => {},
|
setStationOffset: () => {},
|
||||||
|
setStationAlign: () => {},
|
||||||
setSightCoordinates: () => {},
|
setSightCoordinates: () => {},
|
||||||
|
setIconSize: () => {},
|
||||||
|
setFontSize: () => {},
|
||||||
saveChanges: () => {},
|
saveChanges: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +97,7 @@ export const MapDataProvider = observer(
|
|||||||
const [isRouteLoading, setIsRouteLoading] = useState(true);
|
const [isRouteLoading, setIsRouteLoading] = useState(true);
|
||||||
const [isStationLoading, setIsStationLoading] = useState(true);
|
const [isStationLoading, setIsStationLoading] = useState(true);
|
||||||
const [isSightLoading, setIsSightLoading] = useState(true);
|
const [isSightLoading, setIsSightLoading] = useState(true);
|
||||||
|
const [selectedSight, setSelectedSight] = useState<SightData>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -106,17 +117,18 @@ export const MapDataProvider = observer(
|
|||||||
languageInstance("ru").get(`/route/${routeId}/station`),
|
languageInstance("ru").get(`/route/${routeId}/station`),
|
||||||
languageInstance("en").get(`/route/${routeId}/station`),
|
languageInstance("en").get(`/route/${routeId}/station`),
|
||||||
languageInstance("zh").get(`/route/${routeId}/station`),
|
languageInstance("zh").get(`/route/${routeId}/station`),
|
||||||
authInstance.get(`/route/${routeId}/sight`),
|
languageInstance("ru").get(`/route/${routeId}/sight`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setOriginalRouteData(routeResponse.data as RouteData);
|
const routeData = routeResponse.data as RouteData;
|
||||||
|
setOriginalRouteData(routeData);
|
||||||
setOriginalStationData(ruStationResponse.data as StationData[]);
|
setOriginalStationData(ruStationResponse.data as StationData[]);
|
||||||
setStationData({
|
setStationData({
|
||||||
ru: ruStationResponse.data as StationData[],
|
ru: ruStationResponse.data as StationData[],
|
||||||
en: enStationResponse.data as StationData[],
|
en: enStationResponse.data as StationData[],
|
||||||
zh: zhStationResponse.data as StationData[],
|
zh: zhStationResponse.data as StationData[],
|
||||||
});
|
});
|
||||||
setOriginalSightData(sightResponse as unknown as SightData[]);
|
setOriginalSightData(sightResponse.data as SightData[]);
|
||||||
|
|
||||||
setIsRouteLoading(false);
|
setIsRouteLoading(false);
|
||||||
setIsStationLoading(false);
|
setIsStationLoading(false);
|
||||||
@@ -133,7 +145,6 @@ export const MapDataProvider = observer(
|
|||||||
}, [routeId]);
|
}, [routeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// combine changes with original data
|
|
||||||
if (originalRouteData)
|
if (originalRouteData)
|
||||||
setRouteData({ ...originalRouteData, ...routeChanges });
|
setRouteData({ ...originalRouteData, ...routeChanges });
|
||||||
if (originalSightData) setSightData(originalSightData);
|
if (originalSightData) setSightData(originalSightData);
|
||||||
@@ -157,9 +168,57 @@ export const MapDataProvider = observer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMapCenter(x: number, y: number) {
|
function setIconSize(size: number) {
|
||||||
|
const clamped = Math.max(50, Math.min(300, size));
|
||||||
setRouteChanges((prev) => {
|
setRouteChanges((prev) => {
|
||||||
return { ...prev, center_latitude: x, center_longitude: y };
|
if (prev.icon_size === clamped) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, icon_size: clamped };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFontSize(size: number) {
|
||||||
|
const clamped = Math.max(50, Math.min(300, size));
|
||||||
|
setRouteChanges((prev) => {
|
||||||
|
if (prev.font_size === clamped) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, font_size: clamped };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMapCenter(latitude: number, longitude: number) {
|
||||||
|
const epsilon = 1e-6;
|
||||||
|
|
||||||
|
setRouteChanges((prev) => {
|
||||||
|
const prevLat = prev.center_latitude;
|
||||||
|
const prevLon = prev.center_longitude;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevLat !== undefined &&
|
||||||
|
prevLon !== undefined &&
|
||||||
|
Math.abs(prevLat - latitude) < epsilon &&
|
||||||
|
Math.abs(prevLon - longitude) < epsilon
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
center_latitude: latitude,
|
||||||
|
center_longitude: longitude,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setRouteData((routePrev) => {
|
||||||
|
if (!routePrev) return routePrev;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...routePrev,
|
||||||
|
center_latitude: latitude,
|
||||||
|
center_longitude: longitude,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,47 +231,170 @@ export const MapDataProvider = observer(
|
|||||||
async function saveStationChanges() {
|
async function saveStationChanges() {
|
||||||
for (const station of stationChanges) {
|
for (const station of stationChanges) {
|
||||||
await authInstance.patch(`/route/${routeId}/station`, station);
|
await authInstance.patch(`/route/${routeId}/station`, station);
|
||||||
|
|
||||||
|
setStationData((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
Object.keys(updated).forEach((lang) => {
|
||||||
|
updated[lang] = updated[lang].map((s) =>
|
||||||
|
s.id === station.station_id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
offset_x: station.offset_x,
|
||||||
|
offset_y: station.offset_y,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSightChanges() {
|
async function saveSightChanges() {
|
||||||
console.log("sightChanges", sightChanges);
|
|
||||||
for (const sight of sightChanges) {
|
for (const sight of sightChanges) {
|
||||||
await authInstance.patch(`/route/${routeId}/sight`, sight);
|
await authInstance.patch(`/route/${routeId}/sight`, sight);
|
||||||
|
|
||||||
|
setSightData((prev) =>
|
||||||
|
prev
|
||||||
|
? prev.map((s) =>
|
||||||
|
s.id === sight.sight_id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
latitude: sight.latitude,
|
||||||
|
longitude: sight.longitude,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
: prev
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStationOffset(stationId: number, x: number, y: number) {
|
function setStationOffset(stationId: number, x: number, y: number) {
|
||||||
setStationChanges((prev) => {
|
const currentStation = stationData.ru?.find(
|
||||||
let found = prev.find((station) => station.station_id === stationId);
|
(station) => station.id === stationId
|
||||||
if (found) {
|
);
|
||||||
found.offset_x = x;
|
if (
|
||||||
found.offset_y = y;
|
currentStation &&
|
||||||
|
Math.abs(currentStation.offset_x - x) < 0.01 &&
|
||||||
|
Math.abs(currentStation.offset_y - y) < 0.01
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return prev.map((station) => {
|
setStationChanges((prev) => {
|
||||||
if (station.station_id === stationId) {
|
const existingIndex = prev.findIndex(
|
||||||
return found;
|
(station) => station.station_id === stationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
const newChanges = [...prev];
|
||||||
|
newChanges[existingIndex] = {
|
||||||
|
...newChanges[existingIndex],
|
||||||
|
offset_x: x,
|
||||||
|
offset_y: y,
|
||||||
|
};
|
||||||
|
return newChanges;
|
||||||
|
} else {
|
||||||
|
const originalStation = originalStationData?.find(
|
||||||
|
(s) => s.id === stationId
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
station_id: stationId,
|
||||||
|
offset_x: x,
|
||||||
|
offset_y: y,
|
||||||
|
align: originalStation?.align ?? 1,
|
||||||
|
transfers: originalStation?.transfers ?? {
|
||||||
|
bus: "",
|
||||||
|
metro_blue: "",
|
||||||
|
metro_green: "",
|
||||||
|
metro_orange: "",
|
||||||
|
metro_purple: "",
|
||||||
|
metro_red: "",
|
||||||
|
train: "",
|
||||||
|
tram: "",
|
||||||
|
trolleybus: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStationData((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
Object.keys(updated).forEach((lang) => {
|
||||||
|
updated[lang] = updated[lang].map((station) => {
|
||||||
|
if (station.id === stationId) {
|
||||||
|
return { ...station, offset_x: x, offset_y: y };
|
||||||
}
|
}
|
||||||
return station;
|
return station;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStationAlign(stationId: number, align: number) {
|
||||||
|
const currentStation = stationData.ru?.find(
|
||||||
|
(station) => station.id === stationId
|
||||||
|
);
|
||||||
|
if (currentStation && currentStation.align === align) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStationChanges((prev) => {
|
||||||
|
const existingIndex = prev.findIndex(
|
||||||
|
(station) => station.station_id === stationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
const newChanges = [...prev];
|
||||||
|
newChanges[existingIndex] = {
|
||||||
|
...newChanges[existingIndex],
|
||||||
|
align: align,
|
||||||
|
};
|
||||||
|
return newChanges;
|
||||||
} else {
|
} else {
|
||||||
const foundStation = stationData.ru?.find(
|
const originalStation = originalStationData?.find(
|
||||||
(station) => station.id === stationId
|
(s) => s.id === stationId
|
||||||
);
|
);
|
||||||
if (foundStation) {
|
return [
|
||||||
return [
|
...prev,
|
||||||
...prev,
|
{
|
||||||
{
|
station_id: stationId,
|
||||||
station_id: stationId,
|
align: align,
|
||||||
offset_x: x,
|
offset_x: originalStation?.offset_x ?? 0,
|
||||||
offset_y: y,
|
offset_y: originalStation?.offset_y ?? 0,
|
||||||
transfers: foundStation.transfers,
|
transfers: originalStation?.transfers ?? {
|
||||||
|
bus: "",
|
||||||
|
metro_blue: "",
|
||||||
|
metro_green: "",
|
||||||
|
metro_orange: "",
|
||||||
|
metro_purple: "",
|
||||||
|
metro_red: "",
|
||||||
|
train: "",
|
||||||
|
tram: "",
|
||||||
|
trolleybus: "",
|
||||||
},
|
},
|
||||||
];
|
},
|
||||||
}
|
];
|
||||||
return prev;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setStationData((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
Object.keys(updated).forEach((lang) => {
|
||||||
|
updated[lang] = updated[lang].map((station) => {
|
||||||
|
if (station.id === stationId) {
|
||||||
|
return { ...station, align: align };
|
||||||
|
}
|
||||||
|
return station;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSightCoordinates(
|
function setSightCoordinates(
|
||||||
@@ -220,15 +402,27 @@ export const MapDataProvider = observer(
|
|||||||
latitude: number,
|
latitude: number,
|
||||||
longitude: number
|
longitude: number
|
||||||
) {
|
) {
|
||||||
setSightChanges((prev) => {
|
setSightData((prev) =>
|
||||||
let found = prev.find((sight) => sight.sight_id === sightId);
|
prev
|
||||||
if (found) {
|
? prev.map((sight) =>
|
||||||
found.latitude = latitude;
|
sight.id === sightId ? { ...sight, latitude, longitude } : sight
|
||||||
found.longitude = longitude;
|
)
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
|
||||||
return prev.map((sight) => {
|
setSightChanges((prev) => {
|
||||||
if (sight.sight_id === sightId) {
|
const existingIndex = prev.findIndex(
|
||||||
return found;
|
(sight) => sight.sight_id === sightId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
return prev.map((sight, index) => {
|
||||||
|
if (index === existingIndex) {
|
||||||
|
return {
|
||||||
|
...sight,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return sight;
|
return sight;
|
||||||
});
|
});
|
||||||
@@ -249,9 +443,7 @@ export const MapDataProvider = observer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {}, [sightChanges]);
|
||||||
console.log("sightChanges", sightChanges);
|
|
||||||
}, [sightChanges]);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -264,12 +456,17 @@ export const MapDataProvider = observer(
|
|||||||
isRouteLoading,
|
isRouteLoading,
|
||||||
isStationLoading,
|
isStationLoading,
|
||||||
isSightLoading,
|
isSightLoading,
|
||||||
|
selectedSight,
|
||||||
|
setSelectedSight,
|
||||||
setScaleRange,
|
setScaleRange,
|
||||||
setMapRotation,
|
setMapRotation,
|
||||||
setMapCenter,
|
setMapCenter,
|
||||||
saveChanges,
|
saveChanges,
|
||||||
setStationOffset,
|
setStationOffset,
|
||||||
|
setStationAlign,
|
||||||
setSightCoordinates,
|
setSightCoordinates,
|
||||||
|
setIconSize,
|
||||||
|
setFontSize,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
originalRouteData,
|
originalRouteData,
|
||||||
@@ -281,6 +478,9 @@ export const MapDataProvider = observer(
|
|||||||
isRouteLoading,
|
isRouteLoading,
|
||||||
isStationLoading,
|
isStationLoading,
|
||||||
isSightLoading,
|
isSightLoading,
|
||||||
|
selectedSight,
|
||||||
|
setIconSize,
|
||||||
|
setFontSize,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Button, Stack, TextField, Typography } from "@mui/material";
|
import {
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Slider,
|
||||||
|
CircularProgress,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTransform } from "./TransformContext";
|
import { useTransform } from "./TransformContext";
|
||||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
import { SCALE_FACTOR } from "./Constants";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
const {
|
const {
|
||||||
@@ -12,32 +21,40 @@ export function RightSidebar() {
|
|||||||
originalRouteData,
|
originalRouteData,
|
||||||
setMapRotation,
|
setMapRotation,
|
||||||
setMapCenter,
|
setMapCenter,
|
||||||
|
setIconSize: updateIconSize,
|
||||||
|
setFontSize: updateFontSize,
|
||||||
} = useMapData();
|
} = useMapData();
|
||||||
const {
|
const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
|
||||||
rotation,
|
|
||||||
position,
|
|
||||||
screenToLocal,
|
|
||||||
screenCenter,
|
|
||||||
rotateToAngle,
|
|
||||||
setTransform,
|
|
||||||
} = useTransform();
|
|
||||||
const [minScale, setMinScale] = useState<number>(1);
|
const [minScale, setMinScale] = useState<number>(1);
|
||||||
const [maxScale, setMaxScale] = useState<number>(10);
|
const [maxScale, setMaxScale] = useState<number>(5);
|
||||||
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
|
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
});
|
});
|
||||||
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
|
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
|
||||||
|
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
|
||||||
|
const [iconSize, setIconSize] = useState<number>(100);
|
||||||
|
const [fontSize, setFontSize] = useState<number>(100);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (originalRouteData) {
|
if (originalRouteData) {
|
||||||
setMinScale(originalRouteData.scale_min ?? 1);
|
const originalMinScale = originalRouteData.scale_min ?? 1;
|
||||||
setMaxScale(originalRouteData.scale_max ?? 10);
|
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
|
||||||
|
|
||||||
|
const originalMaxScale = originalRouteData.scale_max ?? 5;
|
||||||
|
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
|
||||||
|
|
||||||
|
setMinScale(resetMinScale);
|
||||||
|
setMaxScale(resetMaxScale);
|
||||||
setRotationDegrees(originalRouteData.rotate ?? 0);
|
setRotationDegrees(originalRouteData.rotate ?? 0);
|
||||||
setLocalCenter({
|
setLocalCenter({
|
||||||
x: originalRouteData.center_latitude ?? 0,
|
x: originalRouteData.center_latitude ?? 0,
|
||||||
y: originalRouteData.center_longitude ?? 0,
|
y: originalRouteData.center_longitude ?? 0,
|
||||||
});
|
});
|
||||||
|
setIconSize(originalRouteData.icon_size ?? 100);
|
||||||
|
setFontSize(originalRouteData.font_size ?? 100);
|
||||||
}
|
}
|
||||||
}, [originalRouteData]);
|
}, [originalRouteData]);
|
||||||
|
|
||||||
@@ -52,32 +69,63 @@ export function RightSidebar() {
|
|||||||
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
|
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
|
||||||
);
|
);
|
||||||
}, [rotation]);
|
}, [rotation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMapRotation(rotationDegrees);
|
setMapRotation(rotationDegrees);
|
||||||
}, [rotationDegrees]);
|
}, [rotationDegrees]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const center = screenCenter ?? { x: 0, y: 0 };
|
if (isUserEditing) {
|
||||||
const localCenter = screenToLocal(center.x, center.y);
|
return;
|
||||||
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
|
}
|
||||||
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
|
|
||||||
}, [position]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const latitude = routeData?.center_latitude ?? 0;
|
||||||
setMapCenter(localCenter.x, localCenter.y);
|
const longitude = routeData?.center_longitude ?? 0;
|
||||||
}, [localCenter]);
|
|
||||||
|
setLocalCenter((prev) => {
|
||||||
|
if (
|
||||||
|
Math.abs(prev.x - latitude) < 1e-6 &&
|
||||||
|
Math.abs(prev.y - longitude) < 1e-6
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { x: latitude, y: longitude };
|
||||||
|
});
|
||||||
|
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
|
||||||
|
|
||||||
function setRotationFromDegrees(degrees: number) {
|
function setRotationFromDegrees(degrees: number) {
|
||||||
rotateToAngle((degrees * Math.PI) / 180);
|
rotateToAngle((degrees * Math.PI) / 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pan({ x, y }: { x: number; y: number }) {
|
const handleIconSizeChange = (value: number) => {
|
||||||
const coordinates = coordinatesToLocal(x, y);
|
if (!Number.isFinite(value)) {
|
||||||
setTransform(coordinates.x, coordinates.y);
|
return;
|
||||||
}
|
}
|
||||||
|
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
||||||
|
setIconSize(clamped);
|
||||||
|
updateIconSize(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFontSizeChange = (value: number) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
||||||
|
setFontSize(clamped);
|
||||||
|
updateFontSize(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
|
||||||
|
setIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
|
||||||
|
}, [routeData?.icon_size, originalRouteData?.icon_size]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
|
||||||
|
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
|
||||||
|
}, [routeData?.font_size, originalRouteData?.font_size]);
|
||||||
|
|
||||||
if (!routeData) {
|
if (!routeData) {
|
||||||
console.error("routeData is null");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +143,7 @@ export function RightSidebar() {
|
|||||||
borderRadius={2}
|
borderRadius={2}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||||
Детали о достопримечательностях
|
Настройка маршрута
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={2} direction="row" alignItems="center">
|
<Stack spacing={2} direction="row" alignItems="center">
|
||||||
@@ -104,7 +152,37 @@ export function RightSidebar() {
|
|||||||
label="Минимальный масштаб"
|
label="Минимальный масштаб"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={minScale}
|
value={minScale}
|
||||||
onChange={(e) => setMinScale(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
let newMinScale = Number(e.target.value);
|
||||||
|
|
||||||
|
if (newMinScale < 10) {
|
||||||
|
newMinScale = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMinScale > 300) {
|
||||||
|
newMinScale = 297;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMinScale(newMinScale);
|
||||||
|
|
||||||
|
if (maxScale - newMinScale < 2) {
|
||||||
|
let newMaxScale = newMinScale + 2;
|
||||||
|
|
||||||
|
if (newMaxScale > 300) {
|
||||||
|
newMaxScale = 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMaxScale < 3) {
|
||||||
|
newMaxScale = 3;
|
||||||
|
setMinScale(1);
|
||||||
|
}
|
||||||
|
setMaxScale(newMaxScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMinScale > scale * SCALE_FACTOR) {
|
||||||
|
setScaleAtCenter(newMinScale / SCALE_FACTOR);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiInputLabel-root": {
|
"& .MuiInputLabel-root": {
|
||||||
@@ -116,7 +194,8 @@ export function RightSidebar() {
|
|||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
min: 0.1,
|
min: 1,
|
||||||
|
max: 10,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -125,7 +204,33 @@ export function RightSidebar() {
|
|||||||
label="Максимальный масштаб"
|
label="Максимальный масштаб"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={maxScale}
|
value={maxScale}
|
||||||
onChange={(e) => setMaxScale(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
let newMaxScale = Number(e.target.value);
|
||||||
|
|
||||||
|
if (newMaxScale < 13) {
|
||||||
|
newMaxScale = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMaxScale > 300) {
|
||||||
|
newMaxScale = 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaxScale(newMaxScale);
|
||||||
|
|
||||||
|
if (newMaxScale - minScale < 2) {
|
||||||
|
let newMinScale = newMaxScale - 2;
|
||||||
|
|
||||||
|
if (newMinScale < 1) {
|
||||||
|
newMinScale = 1;
|
||||||
|
setMaxScale(3);
|
||||||
|
}
|
||||||
|
setMinScale(newMinScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMaxScale < scale * SCALE_FACTOR) {
|
||||||
|
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
|
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiInputLabel-root": {
|
"& .MuiInputLabel-root": {
|
||||||
@@ -137,12 +242,127 @@ export function RightSidebar() {
|
|||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
min: 0.1,
|
min: 3,
|
||||||
|
max: 300,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||||
|
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={scale * SCALE_FACTOR}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (typeof newValue === "number") {
|
||||||
|
setScaleAtCenter(newValue / SCALE_FACTOR);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min={minScale}
|
||||||
|
max={maxScale}
|
||||||
|
step={0.1}
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
"& .MuiSlider-thumb": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-track": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-rail": {
|
||||||
|
backgroundColor: "#666",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Текущий масштаб"
|
||||||
|
variant="filled"
|
||||||
|
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newScale = Number(e.target.value);
|
||||||
|
if (
|
||||||
|
!isNaN(newScale) &&
|
||||||
|
newScale >= minScale &&
|
||||||
|
newScale <= maxScale
|
||||||
|
) {
|
||||||
|
setScaleAtCenter(newScale / SCALE_FACTOR);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputLabel-root": {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: minScale,
|
||||||
|
max: maxScale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||||
|
Размер иконок: {iconSize}%
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={iconSize}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
handleIconSizeChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min={50}
|
||||||
|
max={300}
|
||||||
|
step={1}
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
"& .MuiSlider-thumb": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-track": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-rail": {
|
||||||
|
backgroundColor: "#666",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||||
|
Размер шрифта: {fontSize}%
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={fontSize}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
handleFontSizeChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min={50}
|
||||||
|
max={300}
|
||||||
|
step={1}
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
"& .MuiSlider-thumb": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-track": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-rail": {
|
||||||
|
backgroundColor: "#666",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
label="Поворот (в градусах)"
|
label="Поворот (в градусах)"
|
||||||
@@ -181,10 +401,17 @@ export function RightSidebar() {
|
|||||||
type="number"
|
type="number"
|
||||||
label="Центр карты, широта"
|
label="Центр карты, широта"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={Math.round(localCenter.x * 100000) / 100000}
|
value={Math.round(localCenter.x * 1000) / 1000}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
|
setIsUserEditing(true);
|
||||||
pan({ x: Number(e.target.value), y: localCenter.y });
|
const newValue = Number(e.target.value);
|
||||||
|
setLocalCenter((prev) => ({ ...prev, x: newValue }));
|
||||||
|
if (!isNaN(newValue) && localCenter.y !== undefined) {
|
||||||
|
setMapCenter(newValue, localCenter.y);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsUserEditing(false);
|
||||||
}}
|
}}
|
||||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -195,15 +422,25 @@ export function RightSidebar() {
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
inputProps={{
|
||||||
|
step: 0.001,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
label="Центр карты, высота"
|
label="Центр карты, долгота"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={Math.round(localCenter.y * 100000) / 100000}
|
value={Math.round(localCenter.y * 1000) / 1000}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
|
setIsUserEditing(true);
|
||||||
pan({ x: localCenter.x, y: Number(e.target.value) });
|
const newValue = Number(e.target.value);
|
||||||
|
setLocalCenter((prev) => ({ ...prev, y: newValue }));
|
||||||
|
if (!isNaN(newValue) && localCenter.x !== undefined) {
|
||||||
|
setMapCenter(localCenter.x, newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsUserEditing(false);
|
||||||
}}
|
}}
|
||||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -214,19 +451,60 @@ export function RightSidebar() {
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
inputProps={{
|
||||||
|
step: 0.001,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2, position: "relative" }}
|
||||||
onClick={() => {
|
disabled={isSaving}
|
||||||
saveChanges();
|
onClick={async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await saveChanges();
|
||||||
|
toast.success("Изменения сохранены");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Ошибка при сохранении изменений");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сохранить изменения
|
{isSaving ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={20} sx={{ color: "inherit" }} />
|
||||||
|
<span>Сохранение...</span>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
"Сохранить изменения"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
className="absolute bottom-5 left-[-68px] z-100"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M24.0013 0C23.3513 0 22.7013 0.03 22.0413 0.08C10.4213 1 1.01127 10.41 0.0812683 22.03C-0.428732 28.39 1.55127 34.28 5.14127 38.83C5.60127 39.42 5.76127 40.21 5.46127 40.89C4.76127 42.43 3.63127 43.64 3.05127 44.23C2.50127 44.78 1.97127 45.27 1.38127 45.7C0.791268 46.13 0.841268 47.03 1.45127 47.42C2.08127 47.82 3.01127 47.99 4.12127 47.99C6.84127 47.99 10.5813 46.99 13.3013 46.06C13.5013 45.99 13.7013 45.96 13.9113 45.96C14.1813 45.96 14.4613 46.02 14.7113 46.13C17.5713 47.33 20.7113 48 24.0013 48C24.6513 48 25.3213 47.97 25.9813 47.92C37.6313 46.98 47.0613 37.51 47.9313 25.85C48.9913 11.76 37.8713 0 24.0013 0ZM29.5113 37.71C29.4813 37.82 29.3413 37.94 29.2313 37.98C27.7413 38.48 26.2713 39.12 24.7313 39.42C22.9513 39.77 21.1413 39.68 19.5513 38.58C18.2213 37.66 17.7313 36.36 17.8113 34.8C17.9013 32.91 18.5113 31.13 19.0013 29.33C19.5213 27.42 20.1113 25.53 20.4613 23.59C20.9413 20.94 19.7813 20.48 17.3913 20.74C16.8013 20.8 16.2313 21.04 15.5813 21.22C15.7213 20.62 15.8313 20.08 15.9913 19.55C16.0213 19.45 19.6313 17.94 21.4413 17.78C23.3513 17.61 25.2013 17.8 26.6013 19.32C27.3913 20.17 27.6113 21.21 27.5913 22.33C27.5413 24.8 26.5813 27.07 25.9813 29.42C25.6113 30.86 25.2513 32.3 24.9313 33.75C24.8413 34.15 24.8413 34.59 24.8813 35C24.9613 35.97 25.4413 36.39 26.4313 36.57C27.6213 36.78 28.7213 36.45 29.9313 36.04C29.7813 36.66 29.6613 37.19 29.5213 37.7L29.5113 37.71ZM26.8513 15.21C26.6513 15.23 26.4613 15.23 26.2013 15.25C24.4013 15.27 22.7313 14.15 22.2013 12.52C21.5913 10.65 22.5613 8.71 24.5313 7.86C26.7913 6.88 29.5813 8.07 30.2713 10.33C31.0513 12.87 29.0613 14.95 26.8613 15.21H26.8513Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTransform } from "./TransformContext";
|
import { useTransform } from "./TransformContext";
|
||||||
import { SightData } from "./types";
|
import { SightData } from "./types";
|
||||||
import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js";
|
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
|
||||||
|
|
||||||
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
||||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||||
@@ -12,19 +12,21 @@ interface SightProps {
|
|||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sight({ sight, id }: Readonly<SightProps>) {
|
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||||
const { rotation, scale } = useTransform();
|
const { rotation, scale } = useTransform();
|
||||||
const { setSightCoordinates } = useMapData();
|
const { setSightCoordinates, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
const [position, setPosition] = useState(
|
const [position, setPosition] = useState(
|
||||||
coordinatesToLocal(sight.latitude, sight.longitude)
|
coordinatesToLocal(sight.latitude, sight.longitude)
|
||||||
);
|
);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
setIsDragging(true);
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
setStartPosition({
|
setStartPosition({
|
||||||
x: position.x,
|
x: position.x,
|
||||||
y: position.y,
|
y: position.y,
|
||||||
@@ -37,7 +39,18 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
if (!isDragging) return;
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
const dx = e.globalX - startMousePosition.x;
|
||||||
|
const dy = e.globalY - startMousePosition.y;
|
||||||
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||||
|
setIsDragging(true);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
|
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
|
||||||
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
|
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
|
||||||
const cos = Math.cos(rotation);
|
const cos = Math.cos(rotation);
|
||||||
@@ -53,30 +66,33 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(false);
|
||||||
|
|
||||||
|
// Если не было перетаскивания, то это клик
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectedSight(sight);
|
||||||
|
}
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const [texture, setTexture] = useState(Texture.EMPTY);
|
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (texture === Texture.EMPTY) {
|
Assets.load("/sight_icon.svg").then(setTexture);
|
||||||
Assets.load("/SightIcon.png").then((result) => {
|
}, []);
|
||||||
setTexture(result);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [texture]);
|
|
||||||
|
|
||||||
function draw(g: Graphics) {
|
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
|
||||||
g.clear();
|
|
||||||
g.circle(0, 0, 20);
|
|
||||||
g.fill({ color: "#000" }); // Fill circle with primary color
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sight) {
|
if (!sight) {
|
||||||
console.error("sight is null");
|
console.error("sight is null");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Компенсируем масштаб для сохранения постоянного размера
|
||||||
|
const compensatedSize = SIGHT_SIZE / scale;
|
||||||
|
const compensatedFontSize = 24 / scale;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pixiContainer
|
<pixiContainer
|
||||||
rotation={-rotation}
|
rotation={-rotation}
|
||||||
@@ -86,22 +102,34 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
|||||||
onGlobalPointerMove={handlePointerMove}
|
onGlobalPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerUpOutside={handlePointerUp}
|
onPointerUpOutside={handlePointerUp}
|
||||||
x={position.x * UP_SCALE - SIGHT_SIZE / 2} // Offset by half width to center
|
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
|
||||||
y={position.y * UP_SCALE - SIGHT_SIZE / 2} // Offset by half height to center
|
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
|
||||||
>
|
>
|
||||||
<pixiSprite texture={texture} width={SIGHT_SIZE} height={SIGHT_SIZE} />
|
<pixiSprite
|
||||||
<pixiGraphics draw={draw} x={SIGHT_SIZE} y={0} />
|
texture={texture}
|
||||||
|
width={compensatedSize}
|
||||||
|
height={compensatedSize}
|
||||||
|
/>
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g) => {
|
||||||
|
g.clear();
|
||||||
|
g.circle(0, 0, 20 / scale);
|
||||||
|
g.fill({ color: "#000" });
|
||||||
|
}}
|
||||||
|
x={compensatedSize}
|
||||||
|
y={0}
|
||||||
|
/>
|
||||||
<pixiText
|
<pixiText
|
||||||
text={`${id + 1}`}
|
text={`${id + 1}`}
|
||||||
x={SIGHT_SIZE + 1}
|
x={compensatedSize + 1 / scale}
|
||||||
y={0}
|
y={0}
|
||||||
anchor={0.5}
|
anchor={0.5}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 24,
|
fontSize: compensatedFontSize,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
fill: "#ffffff",
|
fill: "#ffffff",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</pixiContainer>
|
</pixiContainer>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
60
src/pages/Route/route-preview/SightInfoWidget.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Box, Typography, IconButton } from "@mui/material";
|
||||||
|
import { Close } from "@mui/icons-material";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
|
||||||
|
export function SightInfoWidget() {
|
||||||
|
const { selectedSight, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
|
if (!selectedSight) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
|
color: "white",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
minWidth: 250,
|
||||||
|
maxWidth: 400,
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}>
|
||||||
|
{selectedSight.name}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedSight(undefined)}
|
||||||
|
sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }}
|
||||||
|
>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}>
|
||||||
|
{selectedSight.address}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: "#999" }}>
|
||||||
|
Город: {selectedSight.city}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
||||||
|
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BACKGROUND_COLOR,
|
BACKGROUND_COLOR,
|
||||||
PATH_COLOR,
|
PATH_COLOR,
|
||||||
@@ -7,140 +10,530 @@ import {
|
|||||||
UP_SCALE,
|
UP_SCALE,
|
||||||
} from "./Constants";
|
} from "./Constants";
|
||||||
import { useTransform } from "./TransformContext";
|
import { useTransform } from "./TransformContext";
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { StationData } from "./types";
|
import { StationData } from "./types";
|
||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
import { coordinatesToLocal } from "./utils";
|
import { coordinatesToLocal } from "./utils";
|
||||||
import { observer } from "mobx-react-lite";
|
import { languageStore } from "@shared";
|
||||||
|
|
||||||
|
declare const pixiContainer: any;
|
||||||
|
declare const pixiGraphics: any;
|
||||||
|
declare const pixiText: any;
|
||||||
|
|
||||||
|
type HorizontalAlign = "left" | "center" | "right";
|
||||||
|
type VerticalAlign = "top" | "center" | "bottom";
|
||||||
|
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
||||||
|
type LabelAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует текстовое позиционирование в anchor координаты.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает координату anchor.x из типа выравнивания.
|
||||||
|
*/
|
||||||
|
|
||||||
interface StationProps {
|
interface StationProps {
|
||||||
station: StationData;
|
station: StationData;
|
||||||
ruLabel: string | null;
|
ruLabel: string | null;
|
||||||
|
anchorPoint?: { x: number; y: number };
|
||||||
|
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
|
||||||
|
labelBlockAnchor?: TextAlign | { x: number; y: number };
|
||||||
|
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
|
||||||
|
labelAlign?: LabelAlign;
|
||||||
|
/** Callback для изменения внутреннего выравнивания */
|
||||||
|
onLabelAlignChange?: (align: LabelAlign) => void;
|
||||||
|
/** Callback для отслеживания наведения на текст */
|
||||||
|
onTextHover?: (isHovered: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Station = observer(
|
interface LabelAlignmentControlProps {
|
||||||
({ station, ruLabel }: Readonly<StationProps>) => {
|
scale: number;
|
||||||
const draw = useCallback((g: Graphics) => {
|
currentAlign: LabelAlign;
|
||||||
|
onAlignChange: (align: LabelAlign) => void;
|
||||||
|
onPointerOver: () => void;
|
||||||
|
onPointerOut: () => void;
|
||||||
|
onControlPointerEnter: () => void;
|
||||||
|
onControlPointerLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StationLabelProps
|
||||||
|
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
|
||||||
|
|
||||||
|
const getAnchorFromOffset = (
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
if (offsetX === 0 && offsetY === 0) {
|
||||||
|
return { x: 0.5, y: 0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const nx = offsetX / length;
|
||||||
|
const ny = offsetY / length;
|
||||||
|
|
||||||
|
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||||
|
scale,
|
||||||
|
currentAlign,
|
||||||
|
onAlignChange,
|
||||||
|
|
||||||
|
onControlPointerEnter,
|
||||||
|
onControlPointerLeave,
|
||||||
|
}) => {
|
||||||
|
const controlHeight = 50 / scale;
|
||||||
|
const controlWidth = 200 / scale;
|
||||||
|
const fontSize = 18 / scale;
|
||||||
|
const borderRadius = 8 / scale;
|
||||||
|
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||||
|
const buttonWidth = controlWidth / 3;
|
||||||
|
const strokeWidth = 2 / scale;
|
||||||
|
|
||||||
|
const drawBg = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
g.roundRect(
|
||||||
|
-controlWidth / 2,
|
||||||
|
0,
|
||||||
|
controlWidth,
|
||||||
|
controlHeight,
|
||||||
|
borderRadius
|
||||||
|
);
|
||||||
|
g.fill({ color: "#1a1a1a" });
|
||||||
|
|
||||||
|
g.roundRect(
|
||||||
|
-controlWidth / 2,
|
||||||
|
0,
|
||||||
|
controlWidth,
|
||||||
|
controlHeight,
|
||||||
|
borderRadius
|
||||||
|
);
|
||||||
|
g.stroke({ color: "#333333", width: strokeWidth });
|
||||||
|
|
||||||
|
for (let i = 1; i < 3; i++) {
|
||||||
|
const x = -controlWidth / 2 + buttonWidth * i;
|
||||||
|
g.moveTo(x, strokeWidth);
|
||||||
|
g.lineTo(x, controlHeight - strokeWidth);
|
||||||
|
g.stroke({ color: "#333333", width: strokeWidth });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawButtonHighlight = useCallback(
|
||||||
|
(g: Graphics, index: number, isActive: boolean) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
const x = -controlWidth / 2 + buttonWidth * index;
|
||||||
|
g.roundRect(
|
||||||
|
x + strokeWidth,
|
||||||
|
strokeWidth,
|
||||||
|
buttonWidth - strokeWidth * 2,
|
||||||
|
controlHeight - strokeWidth * 2,
|
||||||
|
borderRadius / 2
|
||||||
|
);
|
||||||
|
g.fill({ color: "#0066cc", alpha: 0.8 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTextStyle = (isActive: boolean) => ({
|
||||||
|
fontSize,
|
||||||
|
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
|
||||||
|
fill: isActive ? "#ffffff" : "#cccccc",
|
||||||
|
fontFamily: "Arial, sans-serif",
|
||||||
|
});
|
||||||
|
|
||||||
|
const alignOptions = [
|
||||||
|
{ key: "left" as const, label: "Left" },
|
||||||
|
{ key: "center" as const, label: "Center" },
|
||||||
|
{ key: "right" as const, label: "Right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
|
||||||
|
zIndex={999999999999999999}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerOver={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onControlPointerEnter();
|
||||||
|
}}
|
||||||
|
onPointerOut={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onControlPointerLeave();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Основной фон */}
|
||||||
|
<pixiGraphics draw={drawBg} />
|
||||||
|
|
||||||
|
{/* Кнопки с подсветкой */}
|
||||||
|
{alignOptions.map((option, index) => (
|
||||||
|
<pixiContainer key={option.key}>
|
||||||
|
{/* Подсветка активной кнопки */}
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g: Graphics) =>
|
||||||
|
drawButtonHighlight(g, index, option.key === currentAlign)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Текст кнопки */}
|
||||||
|
<pixiText
|
||||||
|
text={option.label}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
position={{
|
||||||
|
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
|
||||||
|
y: controlHeight / 2,
|
||||||
|
}}
|
||||||
|
style={getTextStyle(option.key === currentAlign)}
|
||||||
|
eventMode="static"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAlignChange(option.key);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAlignChange(option.key);
|
||||||
|
}}
|
||||||
|
onPointerOver={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onControlPointerEnter();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
))}
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StationLabel = observer(
|
||||||
|
({
|
||||||
|
station,
|
||||||
|
ruLabel,
|
||||||
|
|
||||||
|
labelAlign: labelAlignProp = "center",
|
||||||
|
onLabelAlignChange,
|
||||||
|
onTextHover,
|
||||||
|
}: Readonly<StationLabelProps>) => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
const { rotation, scale } = useTransform();
|
||||||
|
const { setStationOffset, setStationAlign } = useMapData();
|
||||||
|
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isControlHovered, setIsControlHovered] = useState(false);
|
||||||
|
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
|
||||||
|
const [ruLabelWidth, setRuLabelWidth] = useState(0);
|
||||||
|
|
||||||
|
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||||
|
const mouseStartPos = useRef({ x: 0, y: 0 });
|
||||||
|
const hideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const ruLabelRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerEnter = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
setIsHovered(true);
|
||||||
|
onTextHover?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlPointerEnter = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
setIsControlHovered(true);
|
||||||
|
setIsHovered(true);
|
||||||
|
onTextHover?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlPointerLeave = () => {
|
||||||
|
setIsControlHovered(false);
|
||||||
|
|
||||||
|
if (!isHovered) {
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
onTextHover?.(false);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerLeave = () => {
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
|
||||||
|
if (!isControlHovered) {
|
||||||
|
setIsControlHovered(false);
|
||||||
|
}
|
||||||
|
onTextHover?.(false);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||||
|
}, [station.offset_x, station.offset_y, station.id]);
|
||||||
|
|
||||||
|
const convertNumericAlign = (align: number): LabelAlign => {
|
||||||
|
switch (align) {
|
||||||
|
case 0:
|
||||||
|
return "left";
|
||||||
|
case 1:
|
||||||
|
return "center";
|
||||||
|
case 2:
|
||||||
|
return "right";
|
||||||
|
default:
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertStringAlign = (align: LabelAlign): number => {
|
||||||
|
switch (align) {
|
||||||
|
case "left":
|
||||||
|
return 0;
|
||||||
|
case "center":
|
||||||
|
return 1;
|
||||||
|
case "right":
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
|
||||||
|
}, [station.align]);
|
||||||
|
|
||||||
|
if (!station) return null;
|
||||||
|
|
||||||
|
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||||
|
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||||
|
const compensatedNameFontSize = (16 * 0.75) / scale;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ruLabelRef.current && ruLabel) {
|
||||||
|
setRuLabelWidth(ruLabelRef.current.width);
|
||||||
|
}
|
||||||
|
}, [ruLabel, compensatedRuFontSize]);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
|
dragStartPos.current = { ...position };
|
||||||
|
mouseStartPos.current = { x: e.global.x, y: e.global.y };
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
const dx = e.global.x - mouseStartPos.current.x;
|
||||||
|
const dy = e.global.y - mouseStartPos.current.y;
|
||||||
|
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx_screen = e.global.x - mouseStartPos.current.x;
|
||||||
|
const dy_screen = e.global.y - mouseStartPos.current.y;
|
||||||
|
|
||||||
|
const newPosition = {
|
||||||
|
x: dragStartPos.current.x + dx_screen,
|
||||||
|
y: dragStartPos.current.y + dy_screen,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||||
|
Math.abs(newPosition.y - position.y) > 0.01
|
||||||
|
) {
|
||||||
|
setPosition(newPosition);
|
||||||
|
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(false);
|
||||||
|
setTimeout(() => setIsDragging(false), 50);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlignChange = async (align: LabelAlign) => {
|
||||||
|
setCurrentLabelAlign(align);
|
||||||
|
onLabelAlignChange?.(align);
|
||||||
|
|
||||||
|
const numericAlign = convertStringAlign(align);
|
||||||
|
setStationAlign(station.id, numericAlign);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dynamicAnchor = useMemo(
|
||||||
|
() => getAnchorFromOffset(position.x, position.y),
|
||||||
|
[position.x, position.y]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSecondLabelPosition = (): number => {
|
||||||
|
if (!ruLabelWidth) return 0;
|
||||||
|
|
||||||
|
switch (currentLabelAlign) {
|
||||||
|
case "left":
|
||||||
|
return -ruLabelWidth / 2;
|
||||||
|
case "center":
|
||||||
|
return 0;
|
||||||
|
case "right":
|
||||||
|
return ruLabelWidth / 2;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecondLabelAnchor = (): number => {
|
||||||
|
switch (currentLabelAlign) {
|
||||||
|
case "left":
|
||||||
|
return 0;
|
||||||
|
case "center":
|
||||||
|
return 0.5;
|
||||||
|
case "right":
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
x={coordinates.x * UP_SCALE}
|
||||||
|
y={coordinates.y * UP_SCALE}
|
||||||
|
rotation={-rotation}
|
||||||
|
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
cursor={isDragging ? "grabbing" : "grab"}
|
||||||
|
onPointerOver={handlePointerEnter}
|
||||||
|
onPointerOut={handlePointerLeave}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
>
|
||||||
|
<pixiContainer
|
||||||
|
position={{
|
||||||
|
x:
|
||||||
|
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||||||
|
scale,
|
||||||
|
y:
|
||||||
|
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||||||
|
scale,
|
||||||
|
}}
|
||||||
|
anchor={dynamicAnchor}
|
||||||
|
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||||
|
>
|
||||||
|
{ruLabel && (
|
||||||
|
<pixiText
|
||||||
|
ref={ruLabelRef}
|
||||||
|
text={ruLabel}
|
||||||
|
position={{ x: 0, y: 0 }}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
style={{
|
||||||
|
fontSize: compensatedRuFontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#ffffff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{station.name && language !== "ru" && ruLabel && (
|
||||||
|
<pixiText
|
||||||
|
text={station.name}
|
||||||
|
position={{
|
||||||
|
x: getSecondLabelPosition(),
|
||||||
|
y: compensatedRuFontSize * 1.1,
|
||||||
|
}}
|
||||||
|
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
|
||||||
|
style={{
|
||||||
|
fontSize: compensatedNameFontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#CCCCCC",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(isHovered || isControlHovered) && !isDragging && (
|
||||||
|
<LabelAlignmentControl
|
||||||
|
scale={scale}
|
||||||
|
currentAlign={currentLabelAlign}
|
||||||
|
onAlignChange={handleAlignChange}
|
||||||
|
onPointerOver={handlePointerEnter}
|
||||||
|
onPointerOut={handlePointerLeave}
|
||||||
|
onControlPointerEnter={handleControlPointerEnter}
|
||||||
|
onControlPointerLeave={handleControlPointerLeave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</pixiContainer>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Station = ({
|
||||||
|
station,
|
||||||
|
ruLabel,
|
||||||
|
|
||||||
|
labelAlign,
|
||||||
|
onLabelAlignChange,
|
||||||
|
}: Readonly<StationProps>) => {
|
||||||
|
const [isTextHovered, setIsTextHovered] = useState(false);
|
||||||
|
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
g.clear();
|
g.clear();
|
||||||
const coordinates = coordinatesToLocal(
|
const coordinates = coordinatesToLocal(
|
||||||
station.latitude,
|
station.latitude,
|
||||||
station.longitude
|
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 (
|
const radius = STATION_RADIUS;
|
||||||
<pixiContainer>
|
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||||||
<pixiGraphics draw={draw} />
|
|
||||||
<StationLabel station={station} ruLabel={ruLabel} />
|
|
||||||
</pixiContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const StationLabel = observer(
|
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||||
({ station, ruLabel }: Readonly<StationProps>) => {
|
|
||||||
const { rotation, scale } = useTransform();
|
|
||||||
const { setStationOffset } = useMapData();
|
|
||||||
|
|
||||||
const [position, setPosition] = useState({
|
if (isTextHovered) {
|
||||||
x: station.offset_x,
|
g.fill({ color: 0x00aaff });
|
||||||
y: station.offset_y,
|
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
|
||||||
});
|
} else {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
g.fill({ color: PATH_COLOR });
|
||||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||||
const [startMousePosition, setStartMousePosition] = useState({
|
}
|
||||||
x: 0,
|
},
|
||||||
y: 0,
|
[station.latitude, station.longitude, isTextHovered]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!station) {
|
return (
|
||||||
console.error("station is null");
|
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||||||
return null;
|
<pixiGraphics draw={draw} />
|
||||||
}
|
<StationLabel
|
||||||
|
station={station}
|
||||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
ruLabel={ruLabel}
|
||||||
setIsDragging(true);
|
labelAlign={labelAlign}
|
||||||
setStartPosition({
|
onLabelAlignChange={onLabelAlignChange}
|
||||||
x: position.x,
|
onTextHover={setIsTextHovered}
|
||||||
y: position.y,
|
/>
|
||||||
});
|
</pixiContainer>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ const TransformContext = createContext<{
|
|||||||
rotationDegrees?: number,
|
rotationDegrees?: number,
|
||||||
scale?: number
|
scale?: number
|
||||||
) => void;
|
) => void;
|
||||||
|
setScaleOnly: (newScale: number) => void;
|
||||||
|
setScaleWithoutMovingCenter: (newScale: number) => void;
|
||||||
setScreenCenter: React.Dispatch<
|
setScreenCenter: React.Dispatch<
|
||||||
React.SetStateAction<{ x: number; y: number } | undefined>
|
React.SetStateAction<{ x: number; y: number } | undefined>
|
||||||
>;
|
>;
|
||||||
|
setScaleAtCenter: (newScale: number) => void;
|
||||||
}>({
|
}>({
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -41,10 +44,12 @@ const TransformContext = createContext<{
|
|||||||
localToScreen: () => ({ x: 0, y: 0 }),
|
localToScreen: () => ({ x: 0, y: 0 }),
|
||||||
rotateToAngle: () => {},
|
rotateToAngle: () => {},
|
||||||
setTransform: () => {},
|
setTransform: () => {},
|
||||||
|
setScaleOnly: () => {},
|
||||||
|
setScaleWithoutMovingCenter: () => {},
|
||||||
setScreenCenter: () => {},
|
setScreenCenter: () => {},
|
||||||
|
setScaleAtCenter: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provider component
|
|
||||||
export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
@@ -53,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const screenToLocal = useCallback(
|
const screenToLocal = useCallback(
|
||||||
(screenX: number, screenY: number) => {
|
(screenX: number, screenY: number) => {
|
||||||
// Translate point relative to current pan position
|
|
||||||
const translatedX = (screenX - position.x) / scale;
|
const translatedX = (screenX - position.x) / scale;
|
||||||
const translatedY = (screenY - position.y) / scale;
|
const translatedY = (screenY - position.y) / scale;
|
||||||
|
|
||||||
// Rotate point around center
|
const cosRotation = Math.cos(-rotation);
|
||||||
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
|
|
||||||
const sinRotation = Math.sin(-rotation);
|
const sinRotation = Math.sin(-rotation);
|
||||||
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
|
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
|
||||||
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
|
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
|
||||||
@@ -71,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
[position.x, position.y, scale, rotation]
|
[position.x, position.y, scale, rotation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Inverse of screenToLocal
|
|
||||||
const localToScreen = useCallback(
|
const localToScreen = useCallback(
|
||||||
(localX: number, localY: number) => {
|
(localX: number, localY: number) => {
|
||||||
const upscaledX = localX * UP_SCALE;
|
const upscaledX = localX * UP_SCALE;
|
||||||
@@ -114,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
(currentFromPosition.x - center.x) * sinDelta,
|
(currentFromPosition.x - center.x) * sinDelta,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update both rotation and position in a single batch to avoid stale closure
|
|
||||||
setRotation(to);
|
setRotation(to);
|
||||||
setPosition(newPosition);
|
setPosition(newPosition);
|
||||||
},
|
},
|
||||||
@@ -136,8 +137,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
|
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
|
||||||
const center = screenCenter ?? { x: 0, y: 0 };
|
const center = screenCenter ?? { x: 0, y: 0 };
|
||||||
|
|
||||||
console.log("center", center.x, center.y);
|
|
||||||
|
|
||||||
const newPosition = {
|
const newPosition = {
|
||||||
x: -latitude * UP_SCALE * selectedScale,
|
x: -latitude * UP_SCALE * selectedScale,
|
||||||
y: -longitude * UP_SCALE * selectedScale,
|
y: -longitude * UP_SCALE * selectedScale,
|
||||||
@@ -146,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const cosRot = Math.cos(selectedRotation);
|
const cosRot = Math.cos(selectedRotation);
|
||||||
const sinRot = Math.sin(selectedRotation);
|
const sinRot = Math.sin(selectedRotation);
|
||||||
|
|
||||||
// Translate point relative to center, rotate, then translate back
|
|
||||||
const dx = newPosition.x;
|
const dx = newPosition.x;
|
||||||
const dy = newPosition.y;
|
const dy = newPosition.y;
|
||||||
newPosition.x = dx * cosRot - dy * sinRot + center.x;
|
newPosition.x = dx * cosRot - dy * sinRot + center.x;
|
||||||
newPosition.y = dx * sinRot + dy * cosRot + center.y;
|
newPosition.y = dx * sinRot + dy * cosRot + center.y;
|
||||||
|
|
||||||
// Batch state updates to avoid intermediate renders
|
|
||||||
setPosition(newPosition);
|
setPosition(newPosition);
|
||||||
setRotation(selectedRotation);
|
setRotation(selectedRotation);
|
||||||
setScale(selectedScale);
|
setScale(selectedScale);
|
||||||
@@ -160,6 +157,36 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
[rotation, scale, screenCenter]
|
[rotation, scale, screenCenter]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setScaleAtCenter = useCallback(
|
||||||
|
(newScale: number) => {
|
||||||
|
if (scale === newScale) return;
|
||||||
|
|
||||||
|
const center = screenCenter ?? { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
|
const newPosition = {
|
||||||
|
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
|
||||||
|
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
|
||||||
|
};
|
||||||
|
|
||||||
|
setPosition(newPosition);
|
||||||
|
setScale(newScale);
|
||||||
|
},
|
||||||
|
[position, scale, screenCenter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setScaleOnly = useCallback((newScale: number) => {
|
||||||
|
setScale(newScale);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setScaleWithoutMovingCenter = useCallback(
|
||||||
|
(newScale: number) => {
|
||||||
|
setScale(newScale);
|
||||||
|
},
|
||||||
|
[setScale]
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
position,
|
position,
|
||||||
@@ -173,17 +200,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
screenToLocal,
|
screenToLocal,
|
||||||
localToScreen,
|
localToScreen,
|
||||||
setTransform,
|
setTransform,
|
||||||
|
setScaleOnly,
|
||||||
|
setScaleWithoutMovingCenter,
|
||||||
setScreenCenter,
|
setScreenCenter,
|
||||||
|
setScaleAtCenter,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
position,
|
position,
|
||||||
scale,
|
scale,
|
||||||
rotation,
|
rotation,
|
||||||
screenCenter,
|
screenCenter,
|
||||||
|
setScale,
|
||||||
rotateToAngle,
|
rotateToAngle,
|
||||||
screenToLocal,
|
screenToLocal,
|
||||||
localToScreen,
|
localToScreen,
|
||||||
setTransform,
|
setTransform,
|
||||||
|
setScaleOnly,
|
||||||
|
setScaleWithoutMovingCenter,
|
||||||
|
setScreenCenter,
|
||||||
|
setScaleAtCenter,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -194,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom hook for easy access to transform values
|
|
||||||
export const useTransform = () => {
|
export const useTransform = () => {
|
||||||
const context = useContext(TransformContext);
|
const context = useContext(TransformContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Stack, Typography } from "@mui/material";
|
import { Stack, Typography, Box, IconButton } from "@mui/material";
|
||||||
|
import { Close } from "@mui/icons-material";
|
||||||
|
import { Landmark } from "lucide-react";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
|
||||||
export function Widgets() {
|
export function Widgets() {
|
||||||
|
const { selectedSight, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="column"
|
direction="column"
|
||||||
@@ -24,6 +29,8 @@ export function Widgets() {
|
|||||||
Станция
|
Станция
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||||
<Stack
|
<Stack
|
||||||
bgcolor="primary.main"
|
bgcolor="primary.main"
|
||||||
width={223}
|
width={223}
|
||||||
@@ -31,12 +38,102 @@ export function Widgets() {
|
|||||||
p={2}
|
p={2}
|
||||||
m={2}
|
m={2}
|
||||||
borderRadius={2}
|
borderRadius={2}
|
||||||
alignItems="center"
|
sx={{
|
||||||
justifyContent="center"
|
pointerEvents: "auto",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
{selectedSight ? (
|
||||||
Погода
|
<Box
|
||||||
</Typography>
|
sx={{ height: "100%", display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
{/* Заголовок с кнопкой закрытия */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5 }}>
|
||||||
|
<Landmark size={16} className="shrink-0" />
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ color: "#fff", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
{selectedSight.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedSight(undefined)}
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
p: 0,
|
||||||
|
minWidth: 20,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Описание достопримечательности */}
|
||||||
|
{selectedSight.address && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
mb: 1,
|
||||||
|
opacity: 0.9,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedSight.address}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Город */}
|
||||||
|
{selectedSight.city && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
opacity: 0.7,
|
||||||
|
mt: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Город: {selectedSight.city}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
justifyContent: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Landmark size={32} />
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
|
||||||
|
Выберите достопримечательность
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { Widgets } from "./Widgets";
|
||||||
import { Application, extend } from "@pixi/react";
|
import { extend } from "@pixi/react";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Graphics,
|
Graphics,
|
||||||
@@ -9,21 +9,18 @@ import {
|
|||||||
TilingSprite,
|
TilingSprite,
|
||||||
Text,
|
Text,
|
||||||
} from "pixi.js";
|
} from "pixi.js";
|
||||||
import { Stack } from "@mui/material";
|
import { Box, Stack } from "@mui/material";
|
||||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./TransformContext";
|
import { TransformProvider, useTransform } from "./TransformContext";
|
||||||
import { InfiniteCanvas } from "./InfiniteCanvas";
|
|
||||||
|
|
||||||
import { UP_SCALE } from "./Constants";
|
|
||||||
import { Station } from "./Station";
|
|
||||||
import { TravelPath } from "./TravelPath";
|
|
||||||
import { LeftSidebar } from "./LeftSidebar";
|
import { LeftSidebar } from "./LeftSidebar";
|
||||||
import { RightSidebar } from "./RightSidebar";
|
import { RightSidebar } from "./RightSidebar";
|
||||||
import { Widgets } from "./Widgets";
|
|
||||||
import { coordinatesToLocal } from "./utils";
|
import { coordinatesToLocal } from "./utils";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
|
||||||
import { languageStore } from "@shared";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { UP_SCALE } from "./Constants";
|
||||||
|
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
extend({
|
extend({
|
||||||
Container,
|
Container,
|
||||||
@@ -34,17 +31,50 @@ extend({
|
|||||||
Text,
|
Text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const Loading = () => {
|
||||||
|
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||||
|
|
||||||
|
if (isRouteLoading || isStationLoading || isSightLoading) {
|
||||||
|
return (
|
||||||
|
<div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]">
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
export const RoutePreview = () => {
|
export const RoutePreview = () => {
|
||||||
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<MapDataProvider>
|
<MapDataProvider>
|
||||||
<TransformProvider>
|
<TransformProvider>
|
||||||
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
|
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
|
||||||
<LanguageSwitcher />
|
<Loading />
|
||||||
|
<Box
|
||||||
<LeftSidebar />
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: isLeftSidebarOpen ? 300 : 0,
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
overflow: "visible",
|
||||||
|
height: "100%",
|
||||||
|
bgcolor: "primary.main",
|
||||||
|
borderRight: isLeftSidebarOpen
|
||||||
|
? "1px solid rgba(255,255,255,0.08)"
|
||||||
|
: "none",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LeftSidebar
|
||||||
|
open={isLeftSidebarOpen}
|
||||||
|
onToggle={() => setIsLeftSidebarOpen((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Stack direction="row" flex={1} position="relative" height="100%">
|
<Stack direction="row" flex={1} position="relative" height="100%">
|
||||||
<Widgets />
|
|
||||||
<RouteMap />
|
<RouteMap />
|
||||||
|
<Widgets />
|
||||||
<RightSidebar />
|
<RightSidebar />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -54,16 +84,21 @@ export const RoutePreview = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RouteMap = observer(() => {
|
export const RouteMap = observer(() => {
|
||||||
const { language } = languageStore;
|
const { setPosition, setTransform, screenCenter } = useTransform();
|
||||||
const { setPosition, screenToLocal, setTransform, screenCenter } =
|
|
||||||
useTransform();
|
|
||||||
const { routeData, stationData, sightData, originalRouteData } = useMapData();
|
const { routeData, stationData, sightData, originalRouteData } = useMapData();
|
||||||
console.log(stationData);
|
|
||||||
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
||||||
const [isSetup, setIsSetup] = useState(false);
|
const [isSetup, setIsSetup] = useState(false);
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (originalRouteData) {
|
if (originalRouteData) {
|
||||||
const path = originalRouteData?.path;
|
const path = originalRouteData?.path;
|
||||||
@@ -131,13 +166,12 @@ export const RouteMap = observer(() => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!routeData || !stationData || !sightData) {
|
if (!routeData || !stationData || !sightData) {
|
||||||
console.error("routeData, stationData or sightData is null");
|
return null;
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||||
<Application resizeTo={parentRef} background="#fff">
|
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
|
||||||
<InfiniteCanvas>
|
<InfiniteCanvas>
|
||||||
<TravelPath points={points} />
|
<TravelPath points={points} />
|
||||||
{stationData[language].map((obj, index) => (
|
{stationData[language].map((obj, index) => (
|
||||||
@@ -146,22 +180,17 @@ export const RouteMap = observer(() => {
|
|||||||
key={obj.id}
|
key={obj.id}
|
||||||
ruLabel={
|
ruLabel={
|
||||||
language === "ru"
|
language === "ru"
|
||||||
? stationData.en[index].name
|
? stationData.ru[index].name
|
||||||
: stationData.ru[index].name
|
: stationData.ru[index].name
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{originalSightData?.map((sight: SightData, index: number) => {
|
||||||
<pixiGraphics
|
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||||
draw={(g) => {
|
})}
|
||||||
g.clear();
|
|
||||||
const localCenter = screenToLocal(0, 0);
|
|
||||||
g.circle(localCenter.x, localCenter.y, 10);
|
|
||||||
g.fill("#fff");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InfiniteCanvas>
|
</InfiniteCanvas>
|
||||||
</Application>
|
</Application> */}
|
||||||
|
<WebGLRouteMapPrototype />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,69 +1,74 @@
|
|||||||
export interface RouteData {
|
export interface RouteData {
|
||||||
carrier: string;
|
carrier: string;
|
||||||
carrier_id: number;
|
carrier_id: number;
|
||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
center_longitude: number;
|
center_longitude: number;
|
||||||
governor_appeal: number;
|
icon_size: number;
|
||||||
id: number;
|
font_size: number;
|
||||||
path: [number, number][];
|
governor_appeal: number;
|
||||||
rotate: number;
|
id: number;
|
||||||
route_direction: boolean;
|
path: [number, number][];
|
||||||
route_number: string;
|
rotate: number;
|
||||||
route_sys_number: string;
|
route_direction: boolean;
|
||||||
scale_max: number;
|
route_number: string;
|
||||||
scale_min: number;
|
route_sys_number: string;
|
||||||
|
scale_max: number;
|
||||||
|
scale_min: number;
|
||||||
|
thumbnail?: string; // uuid логотипа маршрута
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationTransferData {
|
export interface StationTransferData {
|
||||||
bus: string;
|
bus: string;
|
||||||
metro_blue: string;
|
metro_blue: string;
|
||||||
metro_green: string;
|
metro_green: string;
|
||||||
metro_orange: string;
|
metro_orange: string;
|
||||||
metro_purple: string;
|
metro_purple: string;
|
||||||
metro_red: string;
|
metro_red: string;
|
||||||
train: string;
|
train: string;
|
||||||
tram: string;
|
tram: string;
|
||||||
trolleybus: string;
|
trolleybus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationData {
|
export interface StationData {
|
||||||
address: string;
|
address: string;
|
||||||
city_id?: number;
|
city_id?: number;
|
||||||
description: string;
|
description: string;
|
||||||
id: number;
|
id: number;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
name: string;
|
name: string;
|
||||||
offset_x: number;
|
offset_x: number;
|
||||||
offset_y: number;
|
offset_y: number;
|
||||||
system_name: string;
|
system_name: string;
|
||||||
transfers: StationTransferData;
|
transfers: StationTransferData;
|
||||||
|
align: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationPatchData {
|
export interface StationPatchData {
|
||||||
station_id: number;
|
station_id: number;
|
||||||
offset_x: number;
|
offset_x: number;
|
||||||
offset_y: number;
|
offset_y: number;
|
||||||
transfers: StationTransferData;
|
align: number;
|
||||||
|
transfers: StationTransferData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SightPatchData {
|
export interface SightPatchData {
|
||||||
sight_id: number;
|
sight_id: number;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SightData {
|
export interface SightData {
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
city_id: number;
|
city_id: number;
|
||||||
id: number;
|
id: number;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
left_article: number;
|
left_article: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
name: string;
|
name: string;
|
||||||
preview_media: number;
|
preview_media: number;
|
||||||
thumbnail: string; // uuid
|
thumbnail: string; // uuid
|
||||||
watermark_lu: string; // uuid
|
watermark_lu: string; // uuid
|
||||||
watermark_rd: string; // uuid
|
watermark_rd: string; // uuid
|
||||||
}
|
}
|
||||||
|
|||||||
203
src/pages/Route/route-preview/web-gl/LanguageSelector.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactElement } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore } from "@shared";
|
||||||
|
|
||||||
|
const LANGUAGES = ["ru", "zh", "en"] as const;
|
||||||
|
type Language = (typeof LANGUAGES)[number];
|
||||||
|
|
||||||
|
type LanguageSelectorProps = {
|
||||||
|
onBack?: () => void;
|
||||||
|
isSidebarOpen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLanguageIcon = (lang: Language): ReactElement => {
|
||||||
|
switch (lang) {
|
||||||
|
case "ru":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 cursor-pointer"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
|
||||||
|
<path
|
||||||
|
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM24.2 33.55H19.92L16.29 26.46H13.11V33.55H9.12V14.18H16.32C18.61 14.18 20.37 14.69 21.62 15.71C22.87 16.73 23.48 18.17 23.48 20.03C23.48 21.35 23.19 22.45 22.62 23.34C22.05 24.22 21.18 24.93 20.02 25.45L24.21 33.37V33.56L24.2 33.55ZM40.3 26.94C40.3 29.06 39.64 30.74 38.31 31.97C36.98 33.2 35.17 33.82 32.87 33.82C30.57 33.82 28.81 33.22 27.48 32.02C26.15 30.82 25.47 29.18 25.44 27.08V14.18H29.43V26.97C29.43 28.24 29.73 29.16 30.34 29.74C30.95 30.32 31.79 30.61 32.86 30.61C35.1 30.61 36.24 29.43 36.28 27.07V14.18H40.28V26.94H40.3Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16.3086 17.4099H13.0986V23.2199H16.3186C17.3186 23.2199 18.0986 22.9599 18.6486 22.4499C19.1986 21.9399 19.4686 21.2399 19.4686 20.3399C19.4686 19.4399 19.2086 18.7099 18.6886 18.1799C18.1686 17.6499 17.3686 17.3899 16.2986 17.3899L16.3086 17.4099Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "zh":
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 cursor-pointer"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
|
||||||
|
<path d="M10.287 20.382H6.291V24.147H10.287V20.382Z" fill={"white"} />
|
||||||
|
<path
|
||||||
|
d="M13.704 24.147H17.721V20.382H13.704V24.147Z"
|
||||||
|
fill={"white"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M36.1254 20.046H29.8575C30.6606 21.9406 31.7187 23.6442 33.0513 25.1217C34.3105 23.6927 35.3315 22.0126 36.1254 20.046Z"
|
||||||
|
fill={"white"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM10.287 13.5H13.704V17.1541H21.117V28.446H17.721V27.375H13.704V33.969H10.287V27.375H6.291V28.5511H3V17.1541H10.287V13.5ZM31.35 13.5H34.704V16.8181H43.083V20.046H39.8887C38.804 22.9506 37.3746 25.3834 35.581 27.4065C37.6488 28.9237 40.1651 30.0542 43.1682 30.7291L43.8469 30.8817L43.3465 31.3649C42.7753 31.9162 41.9777 33.0771 41.5886 33.7939L41.4484 34.0521L41.1642 33.9778C37.8385 33.1088 35.1249 31.7521 32.8974 29.9253C30.6296 31.6954 27.9389 33.0335 24.802 34.015L24.4889 34.1129L24.3502 33.8156C24.0724 33.2203 23.2933 32.029 22.8051 31.439L22.4307 30.9868L22.9986 30.8373C25.936 30.0648 28.4025 28.9702 30.4373 27.4935C28.6775 25.4061 27.319 22.9142 26.2412 20.046H23.097V16.8181H31.35V13.5Z"
|
||||||
|
fill={"white"}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "en":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 cursor-pointer"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
|
||||||
|
<path
|
||||||
|
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM21.57 33.79H8.41V14.15H21.55V17.43H12.45V22.11H20.22V25.28H12.45V30.54H21.57V33.79ZM39.54 33.79H35.49L27.61 20.87V33.79H23.56V14.15H27.61L35.5 27.1V14.15H39.53V33.79H39.54Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollapsedIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 cursor-pointer"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="4" y="3" width="39" height="42" rx="19.5" fill="black" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M0 24C0 10.75 10.75 0 24 0C37.25 0 48 10.75 48 24C48 37.25 37.25 48 24 48C10.75 48 0 37.25 0 24ZM36.05 19.41L38.27 25.47L40.47 19.39L39.17 19.77C39.16 19.75 39.16 19.35 39.16 19.32C38.58 12.41 32.99 7.15 25.94 7.15C25.42 7.15 24.99 7.58 24.99 8.1C24.99 8.62 25.42 9.05 25.94 9.05C31.92 9.05 36.68 13.47 37.26 19.31C37.26 19.325 37.2625 19.435 37.265 19.545C37.2675 19.655 37.27 19.765 37.27 19.78L36.05 19.41ZM8.90375 27.5369C11.3535 26.9568 13.6332 25.8438 15.5701 24.2838L15.5709 24.2846C17.2124 25.8526 19.2497 26.9784 21.4812 27.5521C21.5875 27.5649 21.6945 27.5649 21.8007 27.5521C22.3194 27.6074 22.8298 27.3911 23.1385 26.9848C23.448 26.5786 23.5086 26.0441 23.2986 25.5826C23.0879 25.1211 22.6389 24.803 22.1202 24.7477C20.4804 24.3182 18.9808 23.4937 17.7634 22.3495C20.2489 20.0131 22.1036 17.1245 23.1659 13.9363C23.2729 13.5077 23.165 13.0566 22.8754 12.716C22.6016 12.3627 22.1709 12.1552 21.7136 12.1552H16.6307V10.402C16.6307 9.90121 16.3535 9.43889 15.9045 9.1881C15.4556 8.9373 14.9012 8.9373 14.4522 9.1881C14.0033 9.43809 13.7261 9.90121 13.7261 10.402V12.1824H8.64317C8.12367 12.1824 7.64484 12.45 7.38509 12.8835C7.12534 13.317 7.12534 13.8522 7.38509 14.2857C7.64484 14.7192 8.1245 14.9868 8.64317 14.9868H19.6655C18.6804 16.9971 17.3443 18.828 15.7153 20.3993C14.8373 19.3977 14.0688 18.3128 13.4207 17.1598C13.2747 16.7896 12.9734 16.4972 12.5909 16.3529C12.2083 16.2087 11.7809 16.2279 11.4141 16.405C11.0473 16.5821 10.7751 16.901 10.6647 17.2824C10.5544 17.6638 10.6166 18.0724 10.8357 18.4074C11.6058 19.7423 12.5153 20.997 13.551 22.1516C12.0207 23.3728 10.2307 24.2533 8.30873 24.7317C7.92284 24.7709 7.56932 24.956 7.32617 25.2469C7.08219 25.5369 6.96767 25.9095 7.00833 26.2813C7.04899 26.6539 7.24069 26.9952 7.54193 27.23C7.84235 27.4656 8.22824 27.5761 8.61329 27.5369C8.70956 27.5513 8.80748 27.5513 8.90375 27.5369ZM34.9002 38.8803C35.2512 39.0301 35.6487 39.0397 36.0072 38.9067C36.3815 38.7865 36.6886 38.5237 36.8587 38.18C37.0288 37.8362 37.0462 37.4404 36.9077 37.0839L30.3434 20.7767C30.238 20.5131 30.0529 20.2863 29.8115 20.1261C29.57 19.9658 29.2845 19.8793 28.9924 19.8785C28.7019 19.8785 28.4173 19.9626 28.1766 20.1196C27.9359 20.2775 27.7501 20.501 27.6422 20.7623L21.136 36.523C20.9443 36.9885 21.024 37.5181 21.346 37.9116C21.668 38.305 22.1825 38.5029 22.6962 38.4308C23.2107 38.3579 23.6455 38.0269 23.8372 37.5606L25.4057 33.6489H32.3185L34.1342 38.1079C34.2737 38.4524 34.5492 38.7304 34.9002 38.8803ZM28.9052 25.1652L31.1857 30.8445H31.1849H26.5667L28.9052 25.1652Z"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ArrowIcon = ({ rotation }: { rotation: number }) => (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
}}
|
||||||
|
className="h-12 w-12"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="8" y="7" width="31" height="33" fill="black" />
|
||||||
|
<path
|
||||||
|
d="M24.0001 0C10.7501 0 0.00012207 10.75 0.00012207 24C0.00012207 37.25 10.7501 48 24.0001 48C37.2501 48 48.0001 37.25 48.0001 24C48.0001 10.75 37.2501 0 24.0001 0ZM37.5401 25.84C37.5401 26.4 37.0901 26.85 36.5301 26.85H20.5901C20.1401 26.85 19.9201 27.39 20.2301 27.71L27.6801 35.16C28.0801 35.56 28.0801 36.2 27.6801 36.59L25.0801 39.19C24.6801 39.59 24.0401 39.59 23.6501 39.19L12.4901 28.03L9.17012 24.71C8.77012 24.31 8.77012 23.67 9.17012 23.28L12.4901 19.96L23.6501 8.8C24.0501 8.4 24.6901 8.4 25.0801 8.8L27.6801 11.4C28.0801 11.8 28.0801 12.44 27.6801 12.83L20.2301 20.28C19.9101 20.6 20.1401 21.14 20.5901 21.14H36.5301C37.0901 21.14 37.5401 21.59 37.5401 22.15V25.82V25.84Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LanguageSelector = observer(
|
||||||
|
({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => {
|
||||||
|
const { setLanguage } = languageStore;
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutside = (event: PointerEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handleOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handleOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSelect = (code: Language) => {
|
||||||
|
setLanguage(code);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => setIsOpen((prev) => !prev);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
width: "500px",
|
||||||
|
transition: "width 0.25s ease",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 ">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onBack?.();
|
||||||
|
}}
|
||||||
|
className="flex h-12 w-12 items-center justify-center"
|
||||||
|
aria-label={
|
||||||
|
isOpen ? "Скрыть выбор языка" : "Показать выбор языка"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowIcon rotation={isSidebarOpen ? 0 : 180} />
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
LANGUAGES.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleSelect(lang);
|
||||||
|
}}
|
||||||
|
className="flex h-12 w-12 items-center justify-center"
|
||||||
|
aria-label={`Переключить язык на ${lang.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
{renderLanguageIcon(lang)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex h-12 w-12 items-center justify-center"
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<CollapsedIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
619
src/pages/Sight/LinkedStations.tsx
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
useTheme,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TableBody,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatedCircleButton,
|
||||||
|
authInstance,
|
||||||
|
languageStore,
|
||||||
|
selectedCityStore,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
|
type Field<T> = {
|
||||||
|
label: string;
|
||||||
|
data: keyof T;
|
||||||
|
render?: (value: any) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkedStationsProps<T> = {
|
||||||
|
parentId: string | number;
|
||||||
|
fields: Field<T>[];
|
||||||
|
setItemsParent?: (items: T[]) => void;
|
||||||
|
type: "show" | "edit";
|
||||||
|
onUpdate?: () => void;
|
||||||
|
disableCreation?: boolean;
|
||||||
|
updatedLinkedItems?: T[];
|
||||||
|
refresh?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkedStations = <
|
||||||
|
T extends { id: number; name: string; [key: string]: any }
|
||||||
|
>(
|
||||||
|
props: LinkedStationsProps<T>
|
||||||
|
) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Accordion sx={{ width: "100%" }}>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
background: theme.palette.background.paper,
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
Привязанные остановки
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
|
||||||
|
<AccordionDetails
|
||||||
|
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||||
|
>
|
||||||
|
<Stack gap={2} width="100%">
|
||||||
|
<LinkedStationsContents {...props} />
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkedStationsContentsInner = <
|
||||||
|
T extends { id: number; name: string; [key: string]: any }
|
||||||
|
>({
|
||||||
|
parentId,
|
||||||
|
setItemsParent,
|
||||||
|
fields,
|
||||||
|
type,
|
||||||
|
onUpdate,
|
||||||
|
disableCreation = false,
|
||||||
|
updatedLinkedItems,
|
||||||
|
refresh,
|
||||||
|
}: LinkedStationsProps<T>) => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const [allItems, setAllItems] = useState<T[]>([]);
|
||||||
|
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
|
||||||
|
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
|
||||||
|
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
|
||||||
|
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {}, [error]);
|
||||||
|
|
||||||
|
const parentResource = "sight";
|
||||||
|
const childResource = "station";
|
||||||
|
|
||||||
|
const buildPayload = (ids: number[]) => ({
|
||||||
|
[`${childResource}_ids`]: ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableItems = allItems
|
||||||
|
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||||
|
.filter((item) => {
|
||||||
|
const selectedCityId = selectedCityStore.selectedCityId;
|
||||||
|
if (selectedCityId && "city_id" in item) {
|
||||||
|
return item.city_id === selectedCityId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const filteredAvailableItems = availableItems.filter((item) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const name = String(item.name || "").toLowerCase();
|
||||||
|
const description = String(item.description || "").toLowerCase();
|
||||||
|
return name.includes(query) || description.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updatedLinkedItems) {
|
||||||
|
setLinkedItems(updatedLinkedItems);
|
||||||
|
}
|
||||||
|
}, [updatedLinkedItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItemsParent?.(linkedItems);
|
||||||
|
}, [linkedItems, setItemsParent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedToDetach((prev) => {
|
||||||
|
const updated = new Set<number>();
|
||||||
|
linkedItems.forEach((item) => {
|
||||||
|
if (prev.has(item.id)) {
|
||||||
|
updated.add(item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [linkedItems]);
|
||||||
|
|
||||||
|
const linkItem = () => {
|
||||||
|
if (selectedItemId !== null) {
|
||||||
|
setError(null);
|
||||||
|
const requestData = buildPayload([selectedItemId]);
|
||||||
|
|
||||||
|
setIsLinkingSingle(true);
|
||||||
|
authInstance
|
||||||
|
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||||
|
.then(() => {
|
||||||
|
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||||
|
if (newItem) {
|
||||||
|
setLinkedItems([...linkedItems, newItem]);
|
||||||
|
}
|
||||||
|
setSelectedItemId(null);
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error linking station:", error);
|
||||||
|
setError("Failed to link station");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLinkingSingle(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (itemId: number) => {
|
||||||
|
setError(null);
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
authInstance
|
||||||
|
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||||
|
data: buildPayload([itemId]),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error deleting station:", error);
|
||||||
|
setError("Failed to delete station");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (itemId: number) => {
|
||||||
|
const updated = new Set(selectedItems);
|
||||||
|
if (updated.has(itemId)) {
|
||||||
|
updated.delete(itemId);
|
||||||
|
} else {
|
||||||
|
updated.add(itemId);
|
||||||
|
}
|
||||||
|
setSelectedItems(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkLink = async () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setIsLinkingBulk(true);
|
||||||
|
const idsToLink = Array.from(selectedItems);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authInstance.post(
|
||||||
|
`/${parentResource}/${parentId}/${childResource}`,
|
||||||
|
buildPayload(idsToLink)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
|
||||||
|
setLinkedItems((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((item) => item.id));
|
||||||
|
const additions = newItems.filter((item) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...additions];
|
||||||
|
});
|
||||||
|
setSelectedItems((prev) => {
|
||||||
|
const remaining = new Set(prev);
|
||||||
|
idsToLink.forEach((id) => remaining.delete(id));
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
onUpdate?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error linking stations:", error);
|
||||||
|
setError("Failed to link stations");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLinkingBulk(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDetachSelection = (itemId: number) => {
|
||||||
|
const updated = new Set(selectedToDetach);
|
||||||
|
if (updated.has(itemId)) {
|
||||||
|
updated.delete(itemId);
|
||||||
|
} else {
|
||||||
|
updated.add(itemId);
|
||||||
|
}
|
||||||
|
setSelectedToDetach(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAllDetach = (checked: boolean) => {
|
||||||
|
if (!checked) {
|
||||||
|
setSelectedToDetach(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDetach = async () => {
|
||||||
|
const idsToDetach = Array.from(selectedToDetach);
|
||||||
|
if (idsToDetach.length === 0) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setIsBulkDetaching(true);
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
idsToDetach.forEach((id) => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authInstance.delete(
|
||||||
|
`/${parentResource}/${parentId}/${childResource}`,
|
||||||
|
{
|
||||||
|
data: buildPayload(idsToDetach),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setLinkedItems((prev) =>
|
||||||
|
prev.filter((item) => !idsToDetach.includes(item.id))
|
||||||
|
);
|
||||||
|
setSelectedToDetach((prev) => {
|
||||||
|
const remaining = new Set(prev);
|
||||||
|
idsToDetach.forEach((id) => remaining.delete(id));
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
onUpdate?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting stations:", error);
|
||||||
|
setError("Failed to delete stations");
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
idsToDetach.forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setIsBulkDetaching(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelectedForDetach =
|
||||||
|
linkedItems.length > 0 &&
|
||||||
|
linkedItems.every((item) => selectedToDetach.has(item.id));
|
||||||
|
const isIndeterminateDetach =
|
||||||
|
selectedToDetach.size > 0 && !allSelectedForDetach;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentId) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
authInstance
|
||||||
|
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setLinkedItems(response?.data || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching linked stations:", error);
|
||||||
|
setError("Failed to load linked stations");
|
||||||
|
setLinkedItems([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [parentId, language, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === "edit") {
|
||||||
|
setError(null);
|
||||||
|
authInstance
|
||||||
|
.get(`/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setAllItems(response?.data || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching all stations:", error);
|
||||||
|
setError("Failed to load available stations");
|
||||||
|
setAllItems([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{linkedItems?.length > 0 && (
|
||||||
|
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||||
|
<Table sx={{ width: "100%" }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell width="50px">
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={allSelectedForDetach}
|
||||||
|
indeterminate={isIndeterminateDetach}
|
||||||
|
onChange={(e) => handleToggleAllDetach(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell key="id" width="60px">
|
||||||
|
№
|
||||||
|
</TableCell>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<TableCell key={String(field.data)}>{field.label}</TableCell>
|
||||||
|
))}
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell width="120px">Действие</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{linkedItems.map((item, index) => (
|
||||||
|
<TableRow key={item.id} hover>
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selectedToDetach.has(item.id)}
|
||||||
|
onChange={() => toggleDetachSelection(item.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<TableCell key={String(field.data) + String(idx)}>
|
||||||
|
{field.render
|
||||||
|
? field.render(item[field.data])
|
||||||
|
: item[field.data]}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell>
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteItem(item.id);
|
||||||
|
}}
|
||||||
|
disabled={detachingIds.has(item.id)}
|
||||||
|
loading={detachingIds.has(item.id)}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "edit" && linkedItems.length > 0 && (
|
||||||
|
<Stack direction="row" gap={2} mt={2}>
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={handleBulkDetach}
|
||||||
|
disabled={selectedToDetach.size === 0 || isBulkDetaching}
|
||||||
|
loading={isBulkDetaching}
|
||||||
|
>
|
||||||
|
Отвязать выбранные ({selectedToDetach.size})
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkedItems.length === 0 && !isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
Остановки не найдены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "edit" && !disableCreation && (
|
||||||
|
<Stack gap={2} mt={2}>
|
||||||
|
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_, value) => setActiveTab(value)}
|
||||||
|
variant="fullWidth"
|
||||||
|
>
|
||||||
|
<Tab label="По одной" />
|
||||||
|
<Tab label="Массово" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
value={
|
||||||
|
availableItems?.find(
|
||||||
|
(item) => item.id === selectedItemId
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
setSelectedItemId(newValue?.id || null)
|
||||||
|
}
|
||||||
|
options={availableItems}
|
||||||
|
getOptionLabel={(item) => String(item.name)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите остановку"
|
||||||
|
placeholder="Введите название или описание остановки..."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
option.id === value?.id
|
||||||
|
}
|
||||||
|
filterOptions={(options, { inputValue }) => {
|
||||||
|
if (!inputValue.trim()) return options;
|
||||||
|
const query = inputValue.toLowerCase();
|
||||||
|
return options.filter((option) => {
|
||||||
|
const name = String(option.name || "").toLowerCase();
|
||||||
|
const description = String(
|
||||||
|
option.description || ""
|
||||||
|
).toLowerCase();
|
||||||
|
return (
|
||||||
|
name.includes(query) || description.includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.id}>
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<p>{String(option.name)}</p>
|
||||||
|
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
|
||||||
|
{String(option.description)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="contained"
|
||||||
|
onClick={linkItem}
|
||||||
|
disabled={!selectedItemId || isLinkingSingle}
|
||||||
|
loading={isLinkingSingle}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Поиск остановок"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Введите название или описание остановки..."
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||||
|
<Stack gap={1}>
|
||||||
|
{filteredAvailableItems.map((item) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={item.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
onChange={() => handleCheckboxChange(item.id)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<div className="flex justify-between items-center w-full gap-10">
|
||||||
|
<p>{String(item.name)}</p>
|
||||||
|
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
|
||||||
|
{String(item.description)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
margin: 0,
|
||||||
|
"& .MuiFormControlLabel-label": {
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredAvailableItems.length === 0 && (
|
||||||
|
<Typography
|
||||||
|
color="textSecondary"
|
||||||
|
textAlign="center"
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
{searchQuery.trim()
|
||||||
|
? "Остановки не найдены"
|
||||||
|
: "Нет доступных остановок"}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleBulkLink}
|
||||||
|
disabled={selectedItems.size === 0 || isLinkingBulk}
|
||||||
|
loading={isLinkingBulk}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Добавить выбранные ({selectedItems.size})
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
Загрузка...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" textAlign="center" py={2}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkedStationsContents = observer(
|
||||||
|
LinkedStationsContentsInner
|
||||||
|
) as typeof LinkedStationsContentsInner;
|
||||||
@@ -1,20 +1,38 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { languageStore, sightsStore } from "@shared";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { useEffect, useState } from "react";
|
import {
|
||||||
|
cityStore,
|
||||||
|
languageStore,
|
||||||
|
sightsStore,
|
||||||
|
selectedCityStore,
|
||||||
|
} from "@shared";
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const SightListPage = observer(() => {
|
export const SightListPage = observer(() => {
|
||||||
const { sights, getSights, deleteListSight } = sightsStore;
|
const { sights, getSights, deleteListSight } = sightsStore;
|
||||||
|
const { cities, getCities } = cityStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSights();
|
const fetchSights = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getCities(language);
|
||||||
|
await getSights();
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchSights();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -22,18 +40,40 @@ export const SightListPage = observer(() => {
|
|||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Имя",
|
headerName: "Имя",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "city",
|
field: "city_id",
|
||||||
headerName: "Город",
|
headerName: "Город",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
cities[language].data.find((el) => el.id == params.value)?.name
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -58,10 +98,19 @@ export const SightListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = sights.map((sight) => ({
|
// Фильтрация достопримечательностей по выбранному городу
|
||||||
|
const filteredSights = useMemo(() => {
|
||||||
|
const { selectedCityId } = selectedCityStore;
|
||||||
|
if (!selectedCityId) {
|
||||||
|
return sights;
|
||||||
|
}
|
||||||
|
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||||
|
}, [sights, selectedCityStore.selectedCityId]);
|
||||||
|
|
||||||
|
const rows = filteredSights.map((sight) => ({
|
||||||
id: sight.id,
|
id: sight.id,
|
||||||
name: sight.name,
|
name: sight.name,
|
||||||
city: sight.city,
|
city_id: sight.city_id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,11 +125,41 @@ export const SightListPage = observer(() => {
|
|||||||
path="/sight/create"
|
path="/sight/create"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
|
||||||
hideFooter
|
hideFooter
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||||
|
}}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
"Нет достопримечательностей"
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +177,19 @@ export const SightListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteListSight(id)));
|
||||||
|
getSights();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./SightListPage";
|
export * from "./SightListPage";
|
||||||
|
export { LinkedStations } from "./LinkedStations";
|
||||||
|
|||||||
@@ -1,69 +1,91 @@
|
|||||||
import { Button, Paper, TextField } from "@mui/material";
|
import { Button, TextField } from "@mui/material";
|
||||||
import { snapshotStore } from "@shared";
|
import { snapshotStore } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { runInAction } from "mobx";
|
||||||
|
|
||||||
export const SnapshotCreatePage = observer(() => {
|
export const SnapshotCreatePage = observer(() => {
|
||||||
const { id } = useParams();
|
const { createSnapshot, getSnapshotStatus, snapshotStatus } = snapshotStore;
|
||||||
const { getSnapshot, createSnapshot } = snapshotStore;
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
await getSnapshot(id as string);
|
|
||||||
})();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<div className="w-full h-[400px] flex justify-center items-center">
|
||||||
<div className="flex justify-between items-center">
|
<div className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<button
|
<div className="flex justify-between items-center">
|
||||||
className="flex items-center gap-2"
|
<button
|
||||||
onClick={() => navigate(-1)}
|
className="flex items-center gap-2"
|
||||||
>
|
onClick={() => navigate(-1)}
|
||||||
<ArrowLeft size={20} />
|
>
|
||||||
Назад
|
<ArrowLeft size={20} />
|
||||||
</button>
|
Назад
|
||||||
</div>
|
</button>
|
||||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
</div>
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||||
<TextField
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
className="w-full"
|
<TextField
|
||||||
label="Название"
|
className="w-full"
|
||||||
required
|
label="Название"
|
||||||
value={name}
|
required
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
/>
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await createSnapshot(name);
|
const id = await createSnapshot(name);
|
||||||
setIsLoading(false);
|
|
||||||
toast.success("Снапшот успешно создан");
|
await getSnapshotStatus(id);
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
while (snapshotStore.snapshotStatus?.Status != "done") {
|
||||||
}
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}}
|
await getSnapshotStatus(id);
|
||||||
disabled={isLoading}
|
}
|
||||||
>
|
|
||||||
{isLoading ? (
|
if (snapshotStore.snapshotStatus?.Status === "done") {
|
||||||
<Loader2 size={20} className="animate-spin" />
|
toast.success("Снапшот успешно создан");
|
||||||
) : (
|
|
||||||
"Сохранить"
|
runInAction(() => {
|
||||||
)}
|
snapshotStore.snapshotStatus = null;
|
||||||
</Button>
|
});
|
||||||
|
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Ошибка при создании снапшота");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !name.trim()}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
<span>
|
||||||
|
{snapshotStatus?.Progress
|
||||||
|
? (snapshotStatus.Progress * 100).toFixed(2)
|
||||||
|
: 0}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Сохранить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Paper>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { languageStore, snapshotStore } from "@shared";
|
import { languageStore, snapshotStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const SnapshotListPage = observer(() => {
|
export const SnapshotListPage = observer(() => {
|
||||||
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||||
@@ -14,9 +15,15 @@ export const SnapshotListPage = observer(() => {
|
|||||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSnapshots();
|
const fetchSnapshots = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getSnapshots();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchSnapshots();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -36,6 +43,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 300,
|
width: 300,
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -81,6 +89,15 @@ export const SnapshotListPage = observer(() => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
hideFooterPagination
|
||||||
hideFooter
|
hideFooter
|
||||||
|
loading={isLoading}
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,12 +118,15 @@ export const SnapshotListPage = observer(() => {
|
|||||||
|
|
||||||
<SnapshotRestore
|
<SnapshotRestore
|
||||||
open={isRestoreModalOpen}
|
open={isRestoreModalOpen}
|
||||||
|
loading={isLoading}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
|
setIsLoading(true);
|
||||||
if (rowId) {
|
if (rowId) {
|
||||||
await restoreSnapshot(rowId);
|
await restoreSnapshot(rowId);
|
||||||
}
|
}
|
||||||
setIsRestoreModalOpen(false);
|
setIsRestoreModalOpen(false);
|
||||||
setRowId(null);
|
setRowId(null);
|
||||||
|
setIsLoading(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setIsRestoreModalOpen(false);
|
setIsRestoreModalOpen(false);
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from "./SnapshotListPage";
|
export * from "./SnapshotListPage";
|
||||||
|
|
||||||
export * from "./SnapshotCreatePage";
|
export * from "./SnapshotCreatePage";
|
||||||
|
|||||||
606
src/pages/Station/LinkedSights.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
useTheme,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TableBody,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatedCircleButton,
|
||||||
|
authInstance,
|
||||||
|
languageStore,
|
||||||
|
selectedCityStore,
|
||||||
|
} from "@shared";
|
||||||
|
|
||||||
|
type Field<T> = {
|
||||||
|
label: string;
|
||||||
|
data: keyof T;
|
||||||
|
render?: (value: any) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkedSightsProps<T> = {
|
||||||
|
parentId: string | number;
|
||||||
|
fields: Field<T>[];
|
||||||
|
setItemsParent?: (items: T[]) => void;
|
||||||
|
type: "show" | "edit";
|
||||||
|
onUpdate?: () => void;
|
||||||
|
disableCreation?: boolean;
|
||||||
|
updatedLinkedItems?: T[];
|
||||||
|
refresh?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkedSights = <
|
||||||
|
T extends { id: number; name: string; [key: string]: any }
|
||||||
|
>(
|
||||||
|
props: LinkedSightsProps<T>
|
||||||
|
) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Accordion sx={{ width: "100%" }}>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
background: theme.palette.background.paper,
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
Привязанные достопримечательности
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
|
||||||
|
<AccordionDetails
|
||||||
|
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||||
|
>
|
||||||
|
<Stack gap={2} width="100%">
|
||||||
|
<LinkedSightsContents {...props} />
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkedSightsContentsInner = <
|
||||||
|
T extends { id: number; name: string; [key: string]: any }
|
||||||
|
>({
|
||||||
|
parentId,
|
||||||
|
setItemsParent,
|
||||||
|
fields,
|
||||||
|
type,
|
||||||
|
onUpdate,
|
||||||
|
disableCreation = false,
|
||||||
|
updatedLinkedItems,
|
||||||
|
refresh,
|
||||||
|
}: LinkedSightsProps<T>) => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const [allItems, setAllItems] = useState<T[]>([]);
|
||||||
|
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
|
||||||
|
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
|
||||||
|
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
|
||||||
|
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {}, [error]);
|
||||||
|
|
||||||
|
const parentResource = "station";
|
||||||
|
const childResource = "sight";
|
||||||
|
|
||||||
|
const buildPayload = (ids: number[]) => ({
|
||||||
|
[`${childResource}_ids`]: ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableItems = allItems
|
||||||
|
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||||
|
.filter((item) => {
|
||||||
|
// Фильтруем по городу из навбара
|
||||||
|
const selectedCityId = selectedCityStore.selectedCityId;
|
||||||
|
if (selectedCityId && "city_id" in item) {
|
||||||
|
return item.city_id === selectedCityId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const filteredAvailableItems = availableItems.filter((item) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updatedLinkedItems) {
|
||||||
|
setLinkedItems(updatedLinkedItems);
|
||||||
|
}
|
||||||
|
}, [updatedLinkedItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItemsParent?.(linkedItems);
|
||||||
|
}, [linkedItems, setItemsParent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedToDetach((prev) => {
|
||||||
|
const updated = new Set<number>();
|
||||||
|
linkedItems.forEach((item) => {
|
||||||
|
if (prev.has(item.id)) {
|
||||||
|
updated.add(item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [linkedItems]);
|
||||||
|
|
||||||
|
const linkItem = () => {
|
||||||
|
if (selectedItemId !== null) {
|
||||||
|
setError(null);
|
||||||
|
const requestData = buildPayload([selectedItemId]);
|
||||||
|
|
||||||
|
setIsLinkingSingle(true);
|
||||||
|
authInstance
|
||||||
|
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||||
|
.then(() => {
|
||||||
|
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||||
|
if (newItem) {
|
||||||
|
setLinkedItems([...linkedItems, newItem]);
|
||||||
|
}
|
||||||
|
setSelectedItemId(null);
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error linking sight:", error);
|
||||||
|
setError("Failed to link sight");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLinkingSingle(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (itemId: number) => {
|
||||||
|
setError(null);
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
authInstance
|
||||||
|
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||||
|
data: buildPayload([itemId]),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||||
|
onUpdate?.();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error deleting sight:", error);
|
||||||
|
setError("Failed to delete sight");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (itemId: number) => {
|
||||||
|
const updated = new Set(selectedItems);
|
||||||
|
if (updated.has(itemId)) {
|
||||||
|
updated.delete(itemId);
|
||||||
|
} else {
|
||||||
|
updated.add(itemId);
|
||||||
|
}
|
||||||
|
setSelectedItems(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkLink = async () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setIsLinkingBulk(true);
|
||||||
|
const idsToLink = Array.from(selectedItems);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authInstance.post(
|
||||||
|
`/${parentResource}/${parentId}/${childResource}`,
|
||||||
|
buildPayload(idsToLink)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
|
||||||
|
setLinkedItems((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((item) => item.id));
|
||||||
|
const additions = newItems.filter((item) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...additions];
|
||||||
|
});
|
||||||
|
setSelectedItems((prev) => {
|
||||||
|
const remaining = new Set(prev);
|
||||||
|
idsToLink.forEach((id) => remaining.delete(id));
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
onUpdate?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error linking sights:", error);
|
||||||
|
setError("Failed to link sights");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLinkingBulk(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDetachSelection = (itemId: number) => {
|
||||||
|
const updated = new Set(selectedToDetach);
|
||||||
|
if (updated.has(itemId)) {
|
||||||
|
updated.delete(itemId);
|
||||||
|
} else {
|
||||||
|
updated.add(itemId);
|
||||||
|
}
|
||||||
|
setSelectedToDetach(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAllDetach = (checked: boolean) => {
|
||||||
|
if (!checked) {
|
||||||
|
setSelectedToDetach(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDetach = async () => {
|
||||||
|
const idsToDetach = Array.from(selectedToDetach);
|
||||||
|
if (idsToDetach.length === 0) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setIsBulkDetaching(true);
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
idsToDetach.forEach((id) => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authInstance.delete(
|
||||||
|
`/${parentResource}/${parentId}/${childResource}`,
|
||||||
|
{
|
||||||
|
data: buildPayload(idsToDetach),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setLinkedItems((prev) =>
|
||||||
|
prev.filter((item) => !idsToDetach.includes(item.id))
|
||||||
|
);
|
||||||
|
setSelectedToDetach((prev) => {
|
||||||
|
const remaining = new Set(prev);
|
||||||
|
idsToDetach.forEach((id) => remaining.delete(id));
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
onUpdate?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting sights:", error);
|
||||||
|
setError("Failed to delete sights");
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetachingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
idsToDetach.forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setIsBulkDetaching(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelectedForDetach =
|
||||||
|
linkedItems.length > 0 &&
|
||||||
|
linkedItems.every((item) => selectedToDetach.has(item.id));
|
||||||
|
const isIndeterminateDetach =
|
||||||
|
selectedToDetach.size > 0 && !allSelectedForDetach;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentId) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
authInstance
|
||||||
|
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setLinkedItems(response?.data || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching linked sights:", error);
|
||||||
|
setError("Failed to load linked sights");
|
||||||
|
setLinkedItems([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [parentId, language, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (type === "edit") {
|
||||||
|
setError(null);
|
||||||
|
authInstance
|
||||||
|
.get(`/${childResource}`)
|
||||||
|
.then((response) => {
|
||||||
|
setAllItems(response?.data || []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching all sights:", error);
|
||||||
|
setError("Failed to load available sights");
|
||||||
|
setAllItems([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{linkedItems?.length > 0 && (
|
||||||
|
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||||
|
<Table sx={{ width: "100%" }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell width="50px">
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={allSelectedForDetach}
|
||||||
|
indeterminate={isIndeterminateDetach}
|
||||||
|
onChange={(e) => handleToggleAllDetach(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell key="id" width="60px">
|
||||||
|
№
|
||||||
|
</TableCell>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<TableCell key={String(field.data)}>{field.label}</TableCell>
|
||||||
|
))}
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell width="120px">Действие</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{linkedItems.map((item, index) => (
|
||||||
|
<TableRow key={item.id} hover>
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selectedToDetach.has(item.id)}
|
||||||
|
onChange={() => toggleDetachSelection(item.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<TableCell key={String(field.data) + String(idx)}>
|
||||||
|
{field.render
|
||||||
|
? field.render(item[field.data])
|
||||||
|
: item[field.data]}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell>
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteItem(item.id);
|
||||||
|
}}
|
||||||
|
disabled={detachingIds.has(item.id)}
|
||||||
|
loading={detachingIds.has(item.id)}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "edit" && linkedItems.length > 0 && (
|
||||||
|
<Stack direction="row" gap={2} mt={2}>
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={handleBulkDetach}
|
||||||
|
disabled={selectedToDetach.size === 0 || isBulkDetaching}
|
||||||
|
loading={isBulkDetaching}
|
||||||
|
>
|
||||||
|
Отвязать выбранные ({selectedToDetach.size})
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkedItems.length === 0 && !isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
Достопримечательности не найдены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "edit" && !disableCreation && (
|
||||||
|
<Stack gap={2} mt={2}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Добавить достопримечательности
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_, value) => setActiveTab(value)}
|
||||||
|
variant="fullWidth"
|
||||||
|
>
|
||||||
|
<Tab label="По одной" />
|
||||||
|
<Tab label="Массово" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
value={
|
||||||
|
availableItems?.find(
|
||||||
|
(item) => item.id === selectedItemId
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
setSelectedItemId(newValue?.id || null)
|
||||||
|
}
|
||||||
|
options={availableItems}
|
||||||
|
getOptionLabel={(item) => String(item.name)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите достопримечательность"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
option.id === value?.id
|
||||||
|
}
|
||||||
|
filterOptions={(options, { inputValue }) => {
|
||||||
|
const searchWords = inputValue
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean);
|
||||||
|
return options.filter((option) => {
|
||||||
|
const optionWords = String(option.name)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ");
|
||||||
|
return searchWords.every((searchWord) =>
|
||||||
|
optionWords.some((word) => word.startsWith(searchWord))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.id}>
|
||||||
|
{String(option.name)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="contained"
|
||||||
|
onClick={linkItem}
|
||||||
|
disabled={!selectedItemId || isLinkingSingle}
|
||||||
|
loading={isLinkingSingle}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Поиск достопримечательностей"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Введите название..."
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||||
|
<Stack gap={1}>
|
||||||
|
{filteredAvailableItems.map((item) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={item.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
onChange={() => handleCheckboxChange(item.id)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={String(item.name)}
|
||||||
|
sx={{
|
||||||
|
margin: 0,
|
||||||
|
"& .MuiFormControlLabel-label": {
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredAvailableItems.length === 0 && (
|
||||||
|
<Typography
|
||||||
|
color="textSecondary"
|
||||||
|
textAlign="center"
|
||||||
|
py={1}
|
||||||
|
>
|
||||||
|
{searchQuery.trim()
|
||||||
|
? "Достопримечательности не найдены"
|
||||||
|
: "Нет доступных достопримечательностей"}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<AnimatedCircleButton
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleBulkLink}
|
||||||
|
disabled={selectedItems.size === 0 || isLinkingBulk}
|
||||||
|
loading={isLinkingBulk}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Добавить выбранные ({selectedItems.size})
|
||||||
|
</AnimatedCircleButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
Загрузка...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" textAlign="center" py={2}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkedSightsContents = observer(
|
||||||
|
LinkedSightsContentsInner
|
||||||
|
) as typeof LinkedSightsContentsInner;
|
||||||
@@ -8,35 +8,109 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { stationsStore } from "@shared";
|
import {
|
||||||
import { useState } from "react";
|
stationsStore,
|
||||||
|
languageStore,
|
||||||
|
cityStore,
|
||||||
|
useSelectedCity,
|
||||||
|
} from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
import { SaveWithoutCityAgree } from "@widgets";
|
||||||
|
|
||||||
export const StationCreatePage = observer(() => {
|
export const StationCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [systemName, setSystemName] = useState("");
|
|
||||||
const [direction, setDirection] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { language } = languageStore;
|
||||||
|
const {
|
||||||
|
createStationData,
|
||||||
|
setCreateCommonData,
|
||||||
|
createStation,
|
||||||
|
setLanguageCreateStationData,
|
||||||
|
} = stationsStore;
|
||||||
|
const { cities, getCities } = cityStore;
|
||||||
|
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||||
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
createStationData.common.latitude !== 0 ||
|
||||||
|
createStationData.common.longitude !== 0
|
||||||
|
) {
|
||||||
|
setCoordinates(
|
||||||
|
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
||||||
|
|
||||||
|
const executeCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await stationsStore.createStation(name, systemName, direction);
|
await createStation();
|
||||||
toast.success("Станция успешно создана");
|
toast.success("Остановка успешно создана");
|
||||||
navigate("/station");
|
navigate("/station");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error creating station:", error);
|
||||||
toast.error("Ошибка при создании станции");
|
toast.error("Ошибка при создании станции");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const isCityMissing = !createStationData.common.city_id;
|
||||||
|
|
||||||
|
const isNameMissing =
|
||||||
|
!createStationData.ru.name ||
|
||||||
|
!createStationData.en.name ||
|
||||||
|
!createStationData.zh.name;
|
||||||
|
|
||||||
|
if (isCityMissing || isNameMissing) {
|
||||||
|
setIsSaveWarningOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeCreate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCreate = async () => {
|
||||||
|
setIsSaveWarningOpen(false);
|
||||||
|
await executeCreate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelCreate = () => {
|
||||||
|
setIsSaveWarningOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCities = async () => {
|
||||||
|
await getCities("ru");
|
||||||
|
await getCities("en");
|
||||||
|
await getCities("zh");
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCities();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||||
|
setCreateCommonData({
|
||||||
|
city_id: selectedCityId,
|
||||||
|
city: selectedCity.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<div className="flex justify-between items-center">
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
@@ -45,42 +119,123 @@ export const StationCreatePage = observer(() => {
|
|||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">Создание станции</h1>
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words">Создание остановки</h1>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
fullWidth
|
||||||
label="Название"
|
label="Название"
|
||||||
|
value={createStationData[language].name || ""}
|
||||||
required
|
required
|
||||||
value={name}
|
onChange={(e) =>
|
||||||
onChange={(e) => setName(e.target.value)}
|
setLanguageCreateStationData(language, {
|
||||||
/>
|
name: e.target.value,
|
||||||
<TextField
|
})
|
||||||
className="w-full"
|
}
|
||||||
label="Системное название"
|
|
||||||
required
|
|
||||||
value={systemName}
|
|
||||||
onChange={(e) => setSystemName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Направление</InputLabel>
|
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={direction}
|
labelId="direction-label"
|
||||||
label="Направление"
|
value={createStationData.common.direction ? "Прямой" : "Обратный"}
|
||||||
onChange={(e) => setDirection(e.target.value)}
|
label="Прямой/обратный маршрут"
|
||||||
required
|
onChange={(e) =>
|
||||||
|
setCreateCommonData({
|
||||||
|
direction: e.target.value === "Прямой",
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value="forward">Прямое</MenuItem>
|
<MenuItem value="Прямой">Прямой</MenuItem>
|
||||||
<MenuItem value="backward">Обратное</MenuItem>
|
<MenuItem value="Обратный">Обратный</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Описание"
|
||||||
|
value={createStationData.common.description || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateCommonData({
|
||||||
|
description: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <TextField
|
||||||
|
fullWidth
|
||||||
|
label="Адрес"
|
||||||
|
value={createStationData[language].address || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLanguageCreateStationData(language, {
|
||||||
|
address: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Координаты"
|
||||||
|
value={coordinates}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setCoordinates(newValue);
|
||||||
|
|
||||||
|
const input = newValue.replace(/,/g, " ").trim();
|
||||||
|
const [latStr, lonStr] = input.split(/\s+/);
|
||||||
|
|
||||||
|
const lat = parseFloat(latStr);
|
||||||
|
const lon = parseFloat(lonStr);
|
||||||
|
|
||||||
|
const isValidLat = !isNaN(lat);
|
||||||
|
const isValidLon = !isNaN(lon);
|
||||||
|
|
||||||
|
if (isValidLat && isValidLon) {
|
||||||
|
setCreateCommonData({
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCreateCommonData({
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Город</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={createStationData.common.city_id || ""}
|
||||||
|
label="Город"
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedCity = cities["ru"].data.find(
|
||||||
|
(city) => city.id === e.target.value
|
||||||
|
);
|
||||||
|
setCreateCommonData({
|
||||||
|
city_id: e.target.value as number,
|
||||||
|
city: selectedCity?.name || "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cities["ru"].data.map((city) => (
|
||||||
|
<MenuItem key={city.id} value={city.id}>
|
||||||
|
{city.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
|
||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={isLoading || !name || !systemName || !direction}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
@@ -89,6 +244,16 @@ export const StationCreatePage = observer(() => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||||
|
{isSaveWarningOpen && (
|
||||||
|
<SaveWithoutCityAgree
|
||||||
|
blocker={{
|
||||||
|
proceed: handleConfirmCreate,
|
||||||
|
reset: handleCancelCreate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,19 +6,28 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
Box,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { stationsStore, languageStore, cityStore } from "@shared";
|
import {
|
||||||
|
stationsStore,
|
||||||
|
languageStore,
|
||||||
|
cityStore,
|
||||||
|
LoadingSpinner,
|
||||||
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
import { LinkedSights } from "../LinkedSights";
|
||||||
|
import { SaveWithoutCityAgree } from "@widgets";
|
||||||
|
|
||||||
export const StationEditPage = observer(() => {
|
export const StationEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const {
|
const {
|
||||||
@@ -29,12 +38,30 @@ export const StationEditPage = observer(() => {
|
|||||||
setLanguageEditStationData,
|
setLanguageEditStationData,
|
||||||
} = stationsStore;
|
} = stationsStore;
|
||||||
const { cities, getCities } = cityStore;
|
const { cities, getCities } = cityStore;
|
||||||
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
editStationData.common.latitude !== 0 ||
|
||||||
|
editStationData.common.longitude !== 0
|
||||||
|
) {
|
||||||
|
setCoordinates(
|
||||||
|
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
||||||
|
|
||||||
|
const executeEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await editStation(Number(id));
|
await editStation(Number(id));
|
||||||
toast.success("Станция успешно обновлена");
|
toast.success("Остановка успешно обновлена");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating station:", error);
|
console.error("Error updating station:", error);
|
||||||
toast.error("Ошибка при обновлении станции");
|
toast.error("Ошибка при обновлении станции");
|
||||||
@@ -43,21 +70,72 @@ export const StationEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
const isCityMissing = !editStationData.common.city_id;
|
||||||
|
|
||||||
|
const isNameMissing =
|
||||||
|
!editStationData.ru.name ||
|
||||||
|
!editStationData.en.name ||
|
||||||
|
!editStationData.zh.name;
|
||||||
|
|
||||||
|
if (isCityMissing || isNameMissing) {
|
||||||
|
setIsSaveWarningOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmEdit = async () => {
|
||||||
|
setIsSaveWarningOpen(false);
|
||||||
|
await executeEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsSaveWarningOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAndSetStationData = async () => {
|
const fetchAndSetStationData = async () => {
|
||||||
if (!id) return;
|
if (!id) {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const stationId = Number(id);
|
setIsLoadingData(true);
|
||||||
await getEditStation(stationId);
|
try {
|
||||||
await getCities(language);
|
const stationId = Number(id);
|
||||||
|
await getEditStation(stationId);
|
||||||
|
await getCities("ru");
|
||||||
|
await getCities("en");
|
||||||
|
await getCities("zh");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAndSetStationData();
|
fetchAndSetStationData();
|
||||||
}, [id, language]);
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных станции..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -69,6 +147,9 @@ export const StationEditPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||||
|
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Название"
|
label="Название"
|
||||||
@@ -101,15 +182,15 @@ export const StationEditPage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Описание"
|
label="Описание"
|
||||||
value={editStationData[language].description || ""}
|
value={editStationData.common.description || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setLanguageEditStationData(language, {
|
setEditCommonData({
|
||||||
description: e.target.value,
|
description: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
{/* <TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Адрес"
|
label="Адрес"
|
||||||
value={editStationData[language].address || ""}
|
value={editStationData[language].address || ""}
|
||||||
@@ -118,21 +199,38 @@ export const StationEditPage = observer(() => {
|
|||||||
address: e.target.value,
|
address: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/> */}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Координаты"
|
label="Координаты"
|
||||||
value={`${editStationData.common.latitude} ${editStationData.common.longitude}`}
|
value={coordinates}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const [latitude, longitude] = e.target.value.split(" ").map(Number);
|
const newValue = e.target.value;
|
||||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
setCoordinates(newValue);
|
||||||
|
|
||||||
|
const input = newValue.replace(/,/g, " ").trim();
|
||||||
|
const [latStr, lonStr] = input.split(/\s+/);
|
||||||
|
|
||||||
|
const lat = parseFloat(latStr);
|
||||||
|
const lon = parseFloat(lonStr);
|
||||||
|
|
||||||
|
const isValidLat = !isNaN(lat);
|
||||||
|
const isValidLon = !isNaN(lon);
|
||||||
|
|
||||||
|
if (isValidLat && isValidLon) {
|
||||||
setEditCommonData({
|
setEditCommonData({
|
||||||
latitude: latitude,
|
latitude: lat,
|
||||||
longitude: longitude,
|
longitude: lon,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditCommonData({
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
@@ -141,7 +239,7 @@ export const StationEditPage = observer(() => {
|
|||||||
value={editStationData.common.city_id || ""}
|
value={editStationData.common.city_id || ""}
|
||||||
label="Город"
|
label="Город"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selectedCity = cities[language].find(
|
const selectedCity = cities["ru"].data.find(
|
||||||
(city) => city.id === e.target.value
|
(city) => city.id === e.target.value
|
||||||
);
|
);
|
||||||
setEditCommonData({
|
setEditCommonData({
|
||||||
@@ -150,7 +248,7 @@ export const StationEditPage = observer(() => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cities[language].map((city) => (
|
{cities["ru"].data.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -158,20 +256,38 @@ export const StationEditPage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{id && (
|
||||||
|
<LinkedSights
|
||||||
|
parentId={Number(id)}
|
||||||
|
fields={[{ label: "Название", data: "name" }]}
|
||||||
|
type="edit"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={isLoading || !editStationData[language]?.name}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Обновить"
|
"Сохранить"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||||
|
{isSaveWarningOpen && (
|
||||||
|
<SaveWithoutCityAgree
|
||||||
|
blocker={{
|
||||||
|
proceed: handleConfirmEdit,
|
||||||
|
reset: handleCancelEdit,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { languageStore, stationsStore } from "@shared";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
|
import {
|
||||||
|
languageStore,
|
||||||
|
stationsStore,
|
||||||
|
selectedCityStore,
|
||||||
|
cityStore,
|
||||||
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Trash2, Minus, Route } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
import {
|
||||||
|
CreateButton,
|
||||||
|
DeleteModal,
|
||||||
|
LanguageSwitcher,
|
||||||
|
EditStationTransfersModal,
|
||||||
|
} from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const StationListPage = observer(() => {
|
export const StationListPage = observer(() => {
|
||||||
const { stationLists, getStationList, deleteStation } = stationsStore;
|
const { stationLists, getStationList, deleteStation } = stationsStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const [selectedStationId, setSelectedStationId] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getStationList();
|
const fetchStations = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await cityStore.getCities(language);
|
||||||
|
await getStationList();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchStations();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -22,11 +47,33 @@ export const StationListPage = observer(() => {
|
|||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "system_name",
|
field: "description",
|
||||||
headerName: "Системное название",
|
headerName: "Описание",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "direction",
|
field: "direction",
|
||||||
@@ -50,9 +97,10 @@ export const StationListPage = observer(() => {
|
|||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 140,
|
width: 200,
|
||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +111,15 @@ export const StationListPage = observer(() => {
|
|||||||
<button onClick={() => navigate(`/station/${params.row.id}`)}>
|
<button onClick={() => navigate(`/station/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStationId(params.row.id);
|
||||||
|
setIsTransfersModalOpen(true);
|
||||||
|
}}
|
||||||
|
title="Редактировать пересадки"
|
||||||
|
>
|
||||||
|
<Route size={20} className="text-purple-500" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -77,10 +134,21 @@ export const StationListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = stationLists[language].data.map((station: any) => ({
|
// Фильтрация станций по выбранному городу
|
||||||
|
const filteredStations = () => {
|
||||||
|
const { selectedCityId } = selectedCityStore;
|
||||||
|
if (!selectedCityId) {
|
||||||
|
return stationLists[language].data;
|
||||||
|
}
|
||||||
|
return stationLists[language].data.filter(
|
||||||
|
(station: any) => station.city_id === selectedCityId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = filteredStations().map((station: any) => ({
|
||||||
id: station.id,
|
id: station.id,
|
||||||
name: station.name,
|
name: station.name,
|
||||||
system_name: station.system_name,
|
description: station.description,
|
||||||
direction: station.direction,
|
direction: station.direction,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -88,16 +156,43 @@ export const StationListPage = observer(() => {
|
|||||||
<>
|
<>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<div style={{ width: "100%" }}>
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Станции</h1>
|
<h1 className="text-2xl">Станции</h1>
|
||||||
<CreateButton label="Создать станцию" path="/station/create" />
|
<CreateButton label="Создать остановки" path="/station/create" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
hideFooterPagination
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids) as number[]);
|
||||||
|
}}
|
||||||
hideFooter
|
hideFooter
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,6 +210,28 @@ export const StationListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteStation(id)));
|
||||||
|
getStationList();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditStationTransfersModal
|
||||||
|
open={isTransfersModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsTransfersModalOpen(false);
|
||||||
|
setSelectedStationId(null);
|
||||||
|
}}
|
||||||
|
stationId={selectedStationId}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
import { Paper } from "@mui/material";
|
import { Paper, Box } from "@mui/material";
|
||||||
import { languageStore, stationsStore } from "@shared";
|
import { languageStore, stationsStore, LoadingSpinner } from "@shared";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { LinkedSights } from "../LinkedSights";
|
||||||
|
|
||||||
export const StationPreviewPage = observer(() => {
|
export const StationPreviewPage = observer(() => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { stationPreview, getStationPreview } = stationsStore;
|
const { stationPreview, getStationPreview } = stationsStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
await getStationPreview(Number(id));
|
setIsLoadingData(true);
|
||||||
|
try {
|
||||||
|
await getStationPreview(Number(id));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoadingData(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id, language]);
|
}, [id, language]);
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных станции..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<button
|
<button
|
||||||
@@ -71,6 +95,17 @@ export const StationPreviewPage = observer(() => {
|
|||||||
<p>{stationPreview[id!]?.[language]?.data.description}</p>
|
<p>{stationPreview[id!]?.[language]?.data.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{id && (
|
||||||
|
<LinkedSights
|
||||||
|
parentId={Number(id)}
|
||||||
|
fields={[
|
||||||
|
{ label: "Название", data: "name" },
|
||||||
|
{ label: "Описание", data: "description" },
|
||||||
|
]}
|
||||||
|
type="show"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./StationListPage";
|
|||||||
export * from "./StationCreatePage";
|
export * from "./StationCreatePage";
|
||||||
export * from "./StationPreviewPage";
|
export * from "./StationPreviewPage";
|
||||||
export * from "./StationEditPage";
|
export * from "./StationEditPage";
|
||||||
|
export * from "./LinkedSights";
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
|
Box,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { userStore } from "@shared";
|
import { userStore, languageStore, LoadingSpinner } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const UserEditPage = observer(() => {
|
export const UserEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editUserData, editUser, getUser, setEditUserData } = userStore;
|
const { editUserData, editUser, getUser, setEditUserData } = userStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Устанавливаем русский язык при загрузке страницы
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -35,18 +43,40 @@ export const UserEditPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const data = await getUser(Number(id));
|
setIsLoadingData(true);
|
||||||
|
try {
|
||||||
|
const data = await getUser(Number(id));
|
||||||
|
|
||||||
setEditUserData(
|
setEditUserData(
|
||||||
data?.name || "",
|
data?.name || "",
|
||||||
data?.email || "",
|
data?.email || "",
|
||||||
data?.password || "",
|
data?.password || "",
|
||||||
data?.is_admin || false
|
data?.is_admin || false
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoadingData(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingSpinner message="Загрузка данных пользователя..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -130,7 +160,7 @@ export const UserEditPage = observer(() => {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Обновить"
|
"Сохранить"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { userStore } from "@shared";
|
import { userStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { CreateButton, DeleteModal } from "@widgets";
|
import { CreateButton, DeleteModal } from "@widgets";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const UserListPage = observer(() => {
|
export const UserListPage = observer(() => {
|
||||||
const { users, getUsers, deleteUser } = userStore;
|
const { users, getUsers, deleteUser } = userStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUsers();
|
const fetchUsers = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await getUsers();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -22,11 +31,33 @@ export const UserListPage = observer(() => {
|
|||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Имя",
|
headerName: "Имя",
|
||||||
width: 400,
|
width: 400,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "email",
|
field: "email",
|
||||||
headerName: "Email",
|
headerName: "Email",
|
||||||
width: 400,
|
width: 400,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "is_admin",
|
field: "is_admin",
|
||||||
@@ -52,6 +83,7 @@ export const UserListPage = observer(() => {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -93,11 +125,42 @@ export const UserListPage = observer(() => {
|
|||||||
<h1 className="text-2xl">Пользователи</h1>
|
<h1 className="text-2xl">Пользователи</h1>
|
||||||
<CreateButton label="Создать пользователя" path="/user/create" />
|
<CreateButton label="Создать пользователя" path="/user/create" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
hideFooterPagination
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids) as number[]);
|
||||||
|
}}
|
||||||
hideFooter
|
hideFooter
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
"Нет пользователей"
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,7 +170,6 @@ export const UserListPage = observer(() => {
|
|||||||
if (rowId) {
|
if (rowId) {
|
||||||
await deleteUser(rowId);
|
await deleteUser(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
@@ -116,6 +178,19 @@ export const UserListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteUser(id)));
|
||||||
|
getUsers();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared";
|
import {
|
||||||
|
vehicleStore,
|
||||||
|
VEHICLE_TYPES,
|
||||||
|
carrierStore,
|
||||||
|
languageStore,
|
||||||
|
} from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
@@ -21,10 +26,11 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
const [type, setType] = useState("");
|
const [type, setType] = useState("");
|
||||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
carrierStore.getCarriers();
|
carrierStore.getCarriers(language);
|
||||||
}, []);
|
}, [language]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +38,8 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
await vehicleStore.createVehicle(
|
await vehicleStore.createVehicle(
|
||||||
Number(tailNumber),
|
Number(tailNumber),
|
||||||
Number(type),
|
Number(type),
|
||||||
carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!,
|
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
|
||||||
|
?.full_name as string,
|
||||||
carrierId!
|
carrierId!
|
||||||
);
|
);
|
||||||
toast.success("Транспорт успешно создан");
|
toast.success("Транспорт успешно создан");
|
||||||
@@ -88,7 +95,7 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
required
|
required
|
||||||
onChange={(e) => setCarrierId(e.target.value as number)}
|
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||||
>
|
>
|
||||||
{carrierStore.carriers.data.map((carrier) => (
|
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||||
<MenuItem key={carrier.id} value={carrier.id}>
|
<MenuItem key={carrier.id} value={carrier.id}>
|
||||||
{carrier.full_name}
|
{carrier.full_name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { carrierStore, VEHICLE_TYPES, vehicleStore } from "@shared";
|
import {
|
||||||
|
carrierStore,
|
||||||
|
languageStore,
|
||||||
|
VEHICLE_TYPES,
|
||||||
|
vehicleStore,
|
||||||
|
} from "@shared";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const VehicleEditPage = observer(() => {
|
export const VehicleEditPage = observer(() => {
|
||||||
@@ -25,11 +30,18 @@ export const VehicleEditPage = observer(() => {
|
|||||||
editVehicle,
|
editVehicle,
|
||||||
} = vehicleStore;
|
} = vehicleStore;
|
||||||
const { getCarriers } = carrierStore;
|
const { getCarriers } = carrierStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Устанавливаем русский язык при загрузке страницы
|
||||||
|
languageStore.setLanguage("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await getVehicle(Number(id));
|
await getVehicle(Number(id));
|
||||||
await getCarriers();
|
await getCarriers(language);
|
||||||
|
|
||||||
setEditVehicleData({
|
setEditVehicleData({
|
||||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
|
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
|
||||||
type: vehicle[Number(id)]?.vehicle.type,
|
type: vehicle[Number(id)]?.vehicle.type,
|
||||||
@@ -37,7 +49,7 @@ export const VehicleEditPage = observer(() => {
|
|||||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id, language]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +120,7 @@ export const VehicleEditPage = observer(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{carrierStore.carriers.data.map((carrier) => (
|
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||||
<MenuItem key={carrier.id} value={carrier.id}>
|
<MenuItem key={carrier.id} value={carrier.id}>
|
||||||
{carrier.full_name}
|
{carrier.full_name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal } from "@widgets";
|
import { CreateButton, DeleteModal } from "@widgets";
|
||||||
import { VEHICLE_TYPES } from "@shared";
|
import { VEHICLE_TYPES } from "@shared";
|
||||||
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const VehicleListPage = observer(() => {
|
export const VehicleListPage = observer(() => {
|
||||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||||
const { carriers, getCarriers } = carrierStore;
|
const { carriers, getCarriers } = carrierStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<number | null>(null);
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVehicles();
|
const fetchData = async () => {
|
||||||
getCarriers();
|
setIsLoading(true);
|
||||||
|
await getVehicles();
|
||||||
|
await getCarriers(language);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
@@ -25,17 +35,31 @@ export const VehicleListPage = observer(() => {
|
|||||||
field: "tail_number",
|
field: "tail_number",
|
||||||
headerName: "Бортовой номер",
|
headerName: "Бортовой номер",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "type",
|
field: "type",
|
||||||
headerName: "Тип",
|
headerName: "Тип",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 items-center">
|
<div className="w-full h-full flex items-center">
|
||||||
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
|
{params.value ? (
|
||||||
?.label || params.row.type}
|
VEHICLE_TYPES.find((type) => type.value === params.row.type)
|
||||||
|
?.label || params.row.type
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -44,19 +68,41 @@ export const VehicleListPage = observer(() => {
|
|||||||
field: "carrier",
|
field: "carrier",
|
||||||
headerName: "Перевозчик",
|
headerName: "Перевозчик",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "city",
|
field: "city",
|
||||||
headerName: "Город",
|
headerName: "Город",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center">
|
||||||
|
{params.value ? (
|
||||||
|
params.value
|
||||||
|
) : (
|
||||||
|
<Minus size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 200,
|
width: 200,
|
||||||
align: "center",
|
align: "center",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
@@ -86,7 +132,7 @@ export const VehicleListPage = observer(() => {
|
|||||||
tail_number: vehicle.vehicle.tail_number,
|
tail_number: vehicle.vehicle.tail_number,
|
||||||
type: vehicle.vehicle.type,
|
type: vehicle.vehicle.type,
|
||||||
carrier: vehicle.vehicle.carrier,
|
carrier: vehicle.vehicle.carrier,
|
||||||
city: carriers.data?.find(
|
city: carriers[language].data?.find(
|
||||||
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
||||||
)?.city,
|
)?.city,
|
||||||
}));
|
}));
|
||||||
@@ -101,11 +147,42 @@ export const VehicleListPage = observer(() => {
|
|||||||
path="/vehicle/create"
|
path="/vehicle/create"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-end mb-5 duration-300"
|
||||||
|
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||||
|
{ids.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hideFooterPagination
|
hideFooterPagination
|
||||||
|
checkboxSelection
|
||||||
|
loading={isLoading}
|
||||||
|
onRowSelectionModelChange={(newSelection) => {
|
||||||
|
setIds(Array.from(newSelection.ids) as number[]);
|
||||||
|
}}
|
||||||
hideFooter
|
hideFooter
|
||||||
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
slots={{
|
||||||
|
noRowsOverlay: () => (
|
||||||
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
"Нет транспортных средств"
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,6 +200,19 @@ export const VehicleListPage = observer(() => {
|
|||||||
setRowId(null);
|
setRowId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isBulkDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
await Promise.all(ids.map((id) => deleteVehicle(id)));
|
||||||
|
getVehicles();
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
setIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const authInstance = axios.create({
|
const authInstance = axios.create({
|
||||||
baseURL: "https://wn.krbl.ru",
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
|
|||||||
|
|
||||||
const languageInstance = (language: Language) => {
|
const languageInstance = (language: Language) => {
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: "https://wn.krbl.ru",
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
});
|
});
|
||||||
instance.interceptors.request.use((config) => {
|
instance.interceptors.request.use((config) => {
|
||||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||||
|
|||||||
58
src/shared/config/carrier.svg
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<svg
|
||||||
|
fill="#000000"
|
||||||
|
height="26px"
|
||||||
|
width="26px"
|
||||||
|
version="1.1"
|
||||||
|
id="Capa_1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 489.785 489.785"
|
||||||
|
>
|
||||||
|
<g id="XMLID_196_">
|
||||||
|
<path
|
||||||
|
id="XMLID_203_"
|
||||||
|
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||||
|
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||||
|
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||||
|
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||||
|
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||||
|
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||||
|
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||||
|
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||||
|
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||||
|
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||||
|
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||||
|
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||||
|
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||||
|
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="XMLID_202_"
|
||||||
|
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||||
|
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="XMLID_201_"
|
||||||
|
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||||
|
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||||
|
S194.096,172.676,176.693,160.576z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="XMLID_200_"
|
||||||
|
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||||
|
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||||
|
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||||
|
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="XMLID_197_"
|
||||||
|
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||||
|
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||||
|
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||||
|
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||||
|
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||||
|
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||||
|
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -7,23 +7,23 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Earth,
|
Earth,
|
||||||
Landmark,
|
Landmark,
|
||||||
BusFront,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Car,
|
|
||||||
Table,
|
Table,
|
||||||
Split,
|
Split,
|
||||||
Newspaper,
|
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
Cpu,
|
Cpu,
|
||||||
BookImage,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import carrierIcon from "./carrier.svg";
|
||||||
|
|
||||||
export const DRAWER_WIDTH = 300;
|
export const DRAWER_WIDTH = 300;
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon?: LucideIcon | React.ReactNode;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
for_admin?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
nestedItems?: NavigationItem[];
|
nestedItems?: NavigationItem[];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
@@ -34,30 +34,12 @@ export const NAVIGATION_ITEMS: {
|
|||||||
secondary: NavigationItem[];
|
secondary: NavigationItem[];
|
||||||
} = {
|
} = {
|
||||||
primary: [
|
primary: [
|
||||||
{
|
|
||||||
id: "countries",
|
|
||||||
label: "Страны",
|
|
||||||
icon: Earth,
|
|
||||||
path: "/country",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cities",
|
|
||||||
label: "Города",
|
|
||||||
icon: Building2,
|
|
||||||
path: "/city",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "carriers",
|
|
||||||
label: "Перевозчики",
|
|
||||||
icon: BusFront,
|
|
||||||
path: "/carrier",
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "snapshots",
|
id: "snapshots",
|
||||||
label: "Снапшоты",
|
label: "Снапшоты",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
path: "/snapshot",
|
path: "/snapshot",
|
||||||
|
for_admin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "map",
|
id: "map",
|
||||||
@@ -70,24 +52,20 @@ export const NAVIGATION_ITEMS: {
|
|||||||
label: "Устройства",
|
label: "Устройства",
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
path: "/devices",
|
path: "/devices",
|
||||||
|
for_admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
label: "Пользователи",
|
||||||
|
icon: Users,
|
||||||
|
path: "/user",
|
||||||
|
for_admin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
label: "Все сущности",
|
label: "Справочник",
|
||||||
icon: Table,
|
icon: Table,
|
||||||
nestedItems: [
|
nestedItems: [
|
||||||
{
|
|
||||||
id: "media",
|
|
||||||
label: "Медиа",
|
|
||||||
icon: BookImage,
|
|
||||||
path: "/media",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "articles",
|
|
||||||
label: "Статьи",
|
|
||||||
icon: Newspaper,
|
|
||||||
path: "/article",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "attractions",
|
id: "attractions",
|
||||||
label: "Достопримечательности",
|
label: "Достопримечательности",
|
||||||
@@ -106,20 +84,30 @@ export const NAVIGATION_ITEMS: {
|
|||||||
icon: Split,
|
icon: Split,
|
||||||
path: "/route",
|
path: "/route",
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "vehicles",
|
id: "countries",
|
||||||
label: "Транспорт",
|
label: "Страны",
|
||||||
icon: Car,
|
icon: Earth,
|
||||||
path: "/vehicle",
|
path: "/country",
|
||||||
},
|
for_admin: true,
|
||||||
{
|
},
|
||||||
id: "users",
|
{
|
||||||
label: "Пользователи",
|
id: "cities",
|
||||||
icon: Users,
|
label: "Города",
|
||||||
path: "/user",
|
icon: Building2,
|
||||||
|
path: "/city",
|
||||||
|
for_admin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "carriers",
|
||||||
|
label: "Перевозчики",
|
||||||
|
// @ts-ignore
|
||||||
|
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
||||||
|
path: "/carrier",
|
||||||
|
for_admin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
secondary: [
|
secondary: [
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
export const API_URL = "https://wn.krbl.ru";
|
import * as countries from "i18n-iso-countries";
|
||||||
|
import * as ru from "i18n-iso-countries/langs/ru.json";
|
||||||
|
import * as en from "i18n-iso-countries/langs/en.json";
|
||||||
|
import * as zh from "i18n-iso-countries/langs/zh.json";
|
||||||
|
|
||||||
|
countries.registerLocale(ru);
|
||||||
|
countries.registerLocale(en);
|
||||||
|
countries.registerLocale(zh);
|
||||||
|
|
||||||
|
const generateCountriesList = (locale: string) => {
|
||||||
|
const names = countries.getNames(locale);
|
||||||
|
return Object.entries(names).map(([code, name]) => ({
|
||||||
|
code: code,
|
||||||
|
name: name,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export const MEDIA_TYPE_LABELS = {
|
export const MEDIA_TYPE_LABELS = {
|
||||||
1: "Фото",
|
1: "Фото",
|
||||||
2: "Видео",
|
2: "Видео",
|
||||||
@@ -8,8 +26,10 @@ export const MEDIA_TYPE_LABELS = {
|
|||||||
6: "3Д-модель",
|
6: "3Д-модель",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export * from "./mediaTypes";
|
||||||
|
|
||||||
export const MEDIA_TYPE_VALUES = {
|
export const MEDIA_TYPE_VALUES = {
|
||||||
photo: 1,
|
image: 1,
|
||||||
video: 2,
|
video: 2,
|
||||||
icon: 3,
|
icon: 3,
|
||||||
thumbnail: 3,
|
thumbnail: 3,
|
||||||
@@ -17,4 +37,9 @@ export const MEDIA_TYPE_VALUES = {
|
|||||||
watermark_rd: 4,
|
watermark_rd: 4,
|
||||||
panorama: 5,
|
panorama: 5,
|
||||||
model: 6,
|
model: 6,
|
||||||
|
video_preview: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RU_COUNTRIES = generateCountriesList("ru");
|
||||||
|
export const EN_COUNTRIES = generateCountriesList("en");
|
||||||
|
export const ZH_COUNTRIES = generateCountriesList("zh");
|
||||||
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Допустимые типы и расширения файлов для медиа
|
||||||
|
export const ALLOWED_MEDIA_TYPES = {
|
||||||
|
image: {
|
||||||
|
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||||
|
mimeTypes: [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/bmp",
|
||||||
|
"image/svg+xml",
|
||||||
|
],
|
||||||
|
accept: "image/*",
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||||
|
mimeTypes: [
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"video/ogg",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/x-msvideo",
|
||||||
|
],
|
||||||
|
accept: "video/*",
|
||||||
|
},
|
||||||
|
model3d: {
|
||||||
|
extensions: [".glb", ".gltf"],
|
||||||
|
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||||
|
accept: ".glb,.gltf",
|
||||||
|
},
|
||||||
|
panorama: {
|
||||||
|
extensions: [".jpg", ".jpeg", ".png"],
|
||||||
|
mimeTypes: ["image/jpeg", "image/png"],
|
||||||
|
accept: "image/*",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const getAllAllowedExtensions = (): string[] => {
|
||||||
|
return [
|
||||||
|
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||||
|
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||||
|
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllAcceptString = (): string => {
|
||||||
|
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateFileExtension = (
|
||||||
|
file: File
|
||||||
|
): { valid: boolean; error?: string } => {
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||||
|
const allowedExtensions = getAllAllowedExtensions();
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(extension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||||
|
", "
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterValidFiles = (
|
||||||
|
files: File[]
|
||||||
|
): { validFiles: File[]; errors: string[] } => {
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const validation = validateFileExtension(file);
|
||||||
|
if (validation.valid) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
errors.push(`${file.name}: ${validation.error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { validFiles, errors };
|
||||||
|
};
|
||||||
1
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./useSelectedCity";
|
||||||
12
src/shared/hooks/useSelectedCity.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { selectedCityStore } from "@shared";
|
||||||
|
|
||||||
|
export const useSelectedCity = () => {
|
||||||
|
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedCity,
|
||||||
|
selectedCityId,
|
||||||
|
selectedCityName,
|
||||||
|
hasSelectedCity: !!selectedCity,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -5,3 +5,4 @@ export * from "./store";
|
|||||||
export * from "./const";
|
export * from "./const";
|
||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./modals";
|
export * from "./modals";
|
||||||
|
export * from "./hooks";
|
||||||
|
|||||||
57
src/shared/lib/gltfCacheManager.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
let useGLTF: any = null;
|
||||||
|
|
||||||
|
const initializeUseGLTF = async () => {
|
||||||
|
if (!useGLTF) {
|
||||||
|
try {
|
||||||
|
const drei = await import("@react-three/drei");
|
||||||
|
useGLTF = drei.useGLTF;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return useGLTF;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearGLTFCacheForUrl = async (url: string) => {
|
||||||
|
try {
|
||||||
|
const gltf = await initializeUseGLTF();
|
||||||
|
if (gltf && gltf.clear) {
|
||||||
|
gltf.clear(url);
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllGLTFCache = async () => {
|
||||||
|
try {
|
||||||
|
const gltf = await initializeUseGLTF();
|
||||||
|
if (gltf && gltf.clear) {
|
||||||
|
gltf.clear();
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeBlobURL = (url: string) => {
|
||||||
|
if (url && url.startsWith("blob:")) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearBlobAndGLTFCache = async (url: string) => {
|
||||||
|
revokeBlobURL(url);
|
||||||
|
|
||||||
|
await clearGLTFCacheForUrl(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearMediaTransitionCache = async (
|
||||||
|
previousMediaId: string | number | null,
|
||||||
|
newMediaType?: number
|
||||||
|
) => {
|
||||||
|
if (newMediaType === 6 || previousMediaId) {
|
||||||
|
await clearAllGLTFCache();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,2 +1,35 @@
|
|||||||
export * from "./mui/theme";
|
export * from "./mui/theme";
|
||||||
export * from "./DecodeJWT";
|
export * from "./DecodeJWT";
|
||||||
|
export * from "./gltfCacheManager";
|
||||||
|
|
||||||
|
export const generateDefaultMediaName = (
|
||||||
|
objectName: string,
|
||||||
|
fileName: string,
|
||||||
|
mediaType: number | string,
|
||||||
|
isArticle: boolean = false
|
||||||
|
): string => {
|
||||||
|
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
||||||
|
|
||||||
|
if (isArticle && typeof mediaType === "string") {
|
||||||
|
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
||||||
|
} else if (typeof mediaType === "number") {
|
||||||
|
const mediaTypeLabels: Record<number, string> = {
|
||||||
|
1: "Фото",
|
||||||
|
2: "Видео",
|
||||||
|
3: "Иконка",
|
||||||
|
4: "Водяной знак",
|
||||||
|
5: "Панорама",
|
||||||
|
6: "3Д-модель",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
||||||
|
|
||||||
|
if (objectName && objectName.trim() !== "") {
|
||||||
|
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||||
|
} else {
|
||||||
|
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||||
|
};
|
||||||
|
|||||||
1067
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
@@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
|
|||||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
|
|||||||
className="flex gap-4"
|
className="flex gap-4"
|
||||||
dividers
|
dividers
|
||||||
sx={{
|
sx={{
|
||||||
height: "600px",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
@@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer(
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Тип медиа"
|
label="Тип медиа"
|
||||||
@@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer(
|
|||||||
sx={{ width: "50%" }}
|
sx={{ width: "50%" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box className="flex gap-4 h-full">
|
<Box className="flex gap-4 h-[40vh]">
|
||||||
<Paper
|
<Paper
|
||||||
elevation={2}
|
elevation={2}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -142,7 +141,6 @@ export const PreviewMediaDialog = observer(
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
minHeight: 400,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MediaViewer
|
<MediaViewer
|
||||||
@@ -151,6 +149,8 @@ export const PreviewMediaDialog = observer(
|
|||||||
media_type: media.media_type,
|
media_type: media.media_type,
|
||||||
filename: media.filename,
|
filename: media.filename,
|
||||||
}}
|
}}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
fullHeight
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export const SelectArticleModal = observer(
|
|||||||
onSelectArticle,
|
onSelectArticle,
|
||||||
linkedArticleIds = [],
|
linkedArticleIds = [],
|
||||||
}: SelectArticleModalProps) => {
|
}: SelectArticleModalProps) => {
|
||||||
const { articles, getArticle, getArticleMedia } = articlesStore;
|
const { language } = languageStore;
|
||||||
|
const { articles, getArticle, getArticleMedia, getArticles } =
|
||||||
|
articlesStore;
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
|
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
|
||||||
null
|
null
|
||||||
@@ -54,6 +56,21 @@ export const SelectArticleModal = observer(
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await getArticles("ru");
|
||||||
|
await getArticles("en");
|
||||||
|
await getArticles("zh");
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedArticleId) {
|
||||||
|
handleArticleClick(selectedArticleId);
|
||||||
|
}
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyPress = async (event: KeyboardEvent) => {
|
const handleKeyPress = async (event: KeyboardEvent) => {
|
||||||
if (event.key.toLowerCase() === "enter") {
|
if (event.key.toLowerCase() === "enter") {
|
||||||
@@ -273,6 +290,25 @@ export const SelectArticleModal = observer(
|
|||||||
fontSize: "24px",
|
fontSize: "24px",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
lineHeight: "120%",
|
lineHeight: "120%",
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
textDecoration: "underline",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDoubleClick={async () => {
|
||||||
|
if (selectedArticleId) {
|
||||||
|
const media = await authInstance.get(
|
||||||
|
`/article/${selectedArticleId}/media`
|
||||||
|
);
|
||||||
|
onSelectArticle(
|
||||||
|
selectedArticleId,
|
||||||
|
articlesStore.articleData?.heading || "",
|
||||||
|
articlesStore.articleData?.body || "",
|
||||||
|
media.data || []
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
setSelectedArticleId(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{articlesStore.articleData?.heading || "Название cтатьи"}
|
{articlesStore.articleData?.heading || "Название cтатьи"}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export const SelectMediaDialog = observer(
|
|||||||
filteredMedia = filteredMedia.filter(
|
filteredMedia = filteredMedia.filter(
|
||||||
(mediaItem) => mediaItem.media_type === mediaType
|
(mediaItem) => mediaItem.media_type === mediaType
|
||||||
);
|
);
|
||||||
console.log(filteredMedia);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +162,13 @@ export const SelectMediaDialog = observer(
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemText primary={mediaItem.media_name} />
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
mediaItem.media_name
|
||||||
|
? mediaItem.media_name
|
||||||
|
: mediaItem.filename
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||