Compare commits
29 Commits
2117a6836e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 | |||
| a357994025 | |||
| 7382a85082 | |||
| db64beb3ee | |||
|
|
1abd6b30a4 | ||
|
|
b25df42960 | ||
|
34ba3c1db0
|
|||
| 4f038551a2 | |||
| 470a58a3fa | |||
| 89d7fc2748 | |||
| 97f95fc394 | |||
| bf117ef048 | |||
| ced3067915 | |||
| a908c63771 | |||
| 06eafee3f4 | |||
| 717031cd7a | |||
|
2d4a1e169b
|
|||
| e2547cb571 | |||
| 78800ee2ae | |||
| d415441af8 | |||
| 32a7cb44d1 | |||
| 481385c2f4 |
1
.env
1
.env
@@ -1,2 +1,3 @@
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and yarn.lock
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration (optional, can be added later if needed)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx server
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Variables
|
||||
IMAGE_NAME = white-nights-admin-panel
|
||||
IMAGE_TAG = latest
|
||||
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
ARCHIVE_NAME = white-nights-admin-panel-image.zip
|
||||
|
||||
# Default target
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build-image - Build Docker image"
|
||||
@echo " make export-image - Build Docker image and export it to a zip archive"
|
||||
@echo " make clean - Remove Docker image and zip archive"
|
||||
@echo " make help - Show this help message"
|
||||
|
||||
# Build Docker image
|
||||
.PHONY: build-image
|
||||
build-image:
|
||||
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Export Docker image to zip archive
|
||||
.PHONY: export-image
|
||||
export-image: build-image
|
||||
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
|
||||
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
|
||||
@echo "Image exported successfully to $(ARCHIVE_NAME)"
|
||||
|
||||
# Clean up
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "Removing Docker image and zip archive"
|
||||
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
|
||||
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
|
||||
@echo "Clean up completed"
|
||||
|
||||
# Default target when no arguments provided
|
||||
.DEFAULT_GOAL := help
|
||||
109
package-lock.json
generated
109
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@mui/material": "^7.1.0",
|
||||
"@mui/x-data-grid": "^8.5.1",
|
||||
"@photo-sphere-viewer/core": "^5.13.2",
|
||||
"@pixi/react": "^8.0.2",
|
||||
"@react-three/drei": "^10.1.2",
|
||||
"@react-three/fiber": "^9.1.2",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -25,6 +26,7 @@
|
||||
"mobx-react-lite": "^4.1.0",
|
||||
"ol": "^10.5.0",
|
||||
"path": "^0.12.7",
|
||||
"pixi.js": "^8.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -1201,6 +1203,29 @@
|
||||
"integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pixi/colord": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
|
||||
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pixi/react": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/react/-/react-8.0.2.tgz",
|
||||
"integrity": "sha512-A42Bw/1YlxxCXhb+nDIgzPpACx5dOh7Yi+ZfBMlZ1sBB/qc7qyX9k7bdDtrnMqz8OofyF2FXg9gPw9MISFsQTA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs"
|
||||
],
|
||||
"dependencies": {
|
||||
"its-fine": "^2.0.0",
|
||||
"react-reconciler": "0.31.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pixi.js": "^8.2.6",
|
||||
"react": ">=19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -1476,6 +1501,12 @@
|
||||
"@types/tern": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/css-font-loading-module": {
|
||||
"version": "0.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
|
||||
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -1491,6 +1522,12 @@
|
||||
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/earcut": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
@@ -1980,6 +2017,15 @@
|
||||
"integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
|
||||
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
@@ -3281,6 +3327,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gifuct-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-binary-schema-parser": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -3762,6 +3817,12 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ismobilejs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/its-fine": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
|
||||
@@ -3783,6 +3844,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-binary-schema-parser": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -5005,6 +5072,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
@@ -5091,6 +5164,28 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pixi.js": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz",
|
||||
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
"@types/earcut": "^3.0.0",
|
||||
"@webgpu/types": "^0.1.40",
|
||||
"@xmldom/xmldom": "^0.8.10",
|
||||
"earcut": "^3.0.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"ismobilejs": "^1.1.1",
|
||||
"parse-svg-path": "^0.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/pixijs"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
@@ -6497,20 +6592,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -56,5 +56,6 @@
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
158
src/app/GlobalErrorBoundary.tsx
Normal file
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "background.default",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={64} color="#f44336" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Упс! Что-то пошло не так
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||
перезагрузить страницу или вернуться на главную.
|
||||
</Typography>
|
||||
|
||||
{this.state.error?.message && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
backgroundColor: "error.light",
|
||||
color: "error.contrastText",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||
>
|
||||
Информация об ошибке:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Home size={16} />}
|
||||
onClick={this.handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
На главную
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={this.handleReset}
|
||||
size="large"
|
||||
>
|
||||
Перезагрузить
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { Router } from "./router";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
StationListPage,
|
||||
// VehicleListPage,
|
||||
ArticleListPage,
|
||||
CityPreviewPage,
|
||||
|
||||
// CountryPreviewPage,
|
||||
// VehiclePreviewPage,
|
||||
// CarrierPreviewPage,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
CountryCreatePage,
|
||||
CityCreatePage,
|
||||
CarrierCreatePage,
|
||||
// VehicleCreatePage,
|
||||
VehicleCreatePage,
|
||||
CountryEditPage,
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
RoutePreview,
|
||||
RouteEditPage,
|
||||
ArticlePreviewPage,
|
||||
CountryAddPage,
|
||||
} from "@pages";
|
||||
import { authStore, createSightStore, editSightStore } from "@shared";
|
||||
import { Layout } from "@widgets";
|
||||
@@ -57,7 +58,7 @@ import {
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/sight" replace />;
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -69,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (location.pathname === "/") {
|
||||
return <Navigate to="/sight" replace />;
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -134,12 +135,13 @@ const router = createBrowserRouter([
|
||||
// Country
|
||||
{ path: "country", element: <CountryListPage /> },
|
||||
{ path: "country/create", element: <CountryCreatePage /> },
|
||||
{ path: "country/add", element: <CountryAddPage /> },
|
||||
// { path: "country/:id", element: <CountryPreviewPage /> },
|
||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||
// City
|
||||
{ path: "city", element: <CityListPage /> },
|
||||
{ path: "city/create", element: <CityCreatePage /> },
|
||||
{ path: "city/:id", element: <CityPreviewPage /> },
|
||||
// { path: "city/:id", element: <CityPreviewPage /> },
|
||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||
// Route
|
||||
{ path: "route", element: <RouteListPage /> },
|
||||
@@ -166,7 +168,7 @@ const router = createBrowserRouter([
|
||||
{ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface NavigationItem {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { NavigationItem } from "../model";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authStore } from "@shared";
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: NavigationItem;
|
||||
open: boolean;
|
||||
onClick?: () => void;
|
||||
isNested?: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
@@ -23,15 +25,31 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
open,
|
||||
onClick,
|
||||
isNested = false,
|
||||
onDrawerOpen,
|
||||
}) => {
|
||||
const Icon = item.icon;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const { payload } = authStore;
|
||||
|
||||
// @ts-ignore
|
||||
const isAdmin = payload?.is_admin || false;
|
||||
|
||||
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
||||
|
||||
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (item.id === "all" && !open) {
|
||||
onDrawerOpen?.();
|
||||
}
|
||||
if (item.nestedItems) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else if (onClick) {
|
||||
@@ -103,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{item.nestedItems &&
|
||||
{filteredNestedItems &&
|
||||
filteredNestedItems.length > 0 &&
|
||||
open &&
|
||||
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{item.nestedItems && (
|
||||
{filteredNestedItems && filteredNestedItems.length > 0 && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.nestedItems.map((nestedItem) => (
|
||||
{filteredNestedItems.map((nestedItem) => (
|
||||
<NavigationItemComponent
|
||||
key={nestedItem.id}
|
||||
item={nestedItem}
|
||||
|
||||
@@ -1,34 +1,62 @@
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { NAVIGATION_ITEMS } from "@shared";
|
||||
import { authStore, NAVIGATION_ITEMS } from "@shared";
|
||||
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const NavigationList = ({ open }: { open: boolean }) => {
|
||||
const primaryItems = NAVIGATION_ITEMS.primary;
|
||||
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
||||
interface NavigationListProps {
|
||||
open: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{secondaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const NavigationList = observer(
|
||||
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const { payload } = authStore;
|
||||
// @ts-ignore
|
||||
const isAdmin = Boolean(payload?.is_admin) || false;
|
||||
|
||||
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
||||
if (item.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
if (item.nestedItems && item.nestedItems.length > 0) {
|
||||
return item.nestedItems.some((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{NAVIGATION_ITEMS.secondary.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,14 +2,19 @@ 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">Создание статьи</h1>
|
||||
<h1 className="text-3xl break-words">
|
||||
{articleData?.ru?.heading || "Создание статьи"}
|
||||
</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore } from "@shared";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const ArticleEditPage: React.FC = observer(() => {
|
||||
@@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => {
|
||||
|
||||
const { articleData, getArticle } = articlesStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
// Fetch data for all languages
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Eye, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const ArticleListPage = observer(() => {
|
||||
const { articleList, getArticleList, deleteArticles } = articlesStore;
|
||||
@@ -14,9 +16,15 @@ export const ArticleListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getArticleList();
|
||||
const fetchArticles = async () => {
|
||||
setIsLoading(true);
|
||||
await getArticleList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchArticles();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -38,6 +46,7 @@ export const ArticleListPage = observer(() => {
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -93,10 +102,21 @@ export const ArticleListPage = observer(() => {
|
||||
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>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const PreviewRightWidget = observer(() => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MediaViewer media={articleMedia} fullWidth />
|
||||
<MediaViewer media={articleMedia} fullWidth fullHeight />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
|
||||
@@ -12,7 +12,13 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore, cityStore, mediaStore } from "@shared";
|
||||
import {
|
||||
carrierStore,
|
||||
cityStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
@@ -23,11 +29,9 @@ import {
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [shortName, setShortName] = useState("");
|
||||
const [cityId, setCityId] = useState<number | null>(null);
|
||||
const [slogan, setSlogan] = useState("");
|
||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const { selectedCityId } = useSelectedCity();
|
||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
@@ -35,24 +39,34 @@ export const CarrierCreatePage = observer(() => {
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | null
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cityStore.getCities("ru");
|
||||
mediaStore.getMedia();
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && !createCarrierData.city_id) {
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await carrierStore.createCarrier(
|
||||
fullName,
|
||||
shortName,
|
||||
cityId!,
|
||||
slogan,
|
||||
selectedMediaId!
|
||||
);
|
||||
await carrierStore.createCarrier();
|
||||
|
||||
toast.success("Перевозчик успешно создан");
|
||||
navigate("/carrier");
|
||||
} catch (error) {
|
||||
@@ -69,6 +83,14 @@ export const CarrierCreatePage = observer(() => {
|
||||
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
|
||||
@@ -89,19 +111,28 @@ export const CarrierCreatePage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">Создание перевозчика</h1>
|
||||
</div>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={cityId || ""}
|
||||
value={createCarrierData.city_id || ""}
|
||||
label="Город"
|
||||
required
|
||||
onChange={(e) => setCityId(e.target.value as number)}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
e.target.value as number,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities.ru.data.map((city) => (
|
||||
{cityStore.cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -112,24 +143,51 @@ export const CarrierCreatePage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Полное название"
|
||||
value={fullName}
|
||||
value={createCarrierData[language].full_name}
|
||||
required
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
e.target.value,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Короткое название"
|
||||
value={shortName}
|
||||
value={createCarrierData[language].short_name}
|
||||
required
|
||||
onChange={(e) => setShortName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={slogan}
|
||||
onChange={(e) => setSlogan(e.target.value)}
|
||||
value={createCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
@@ -144,14 +202,22 @@ export const CarrierCreatePage = observer(() => {
|
||||
onDeleteImageClick={() => {
|
||||
setSelectedMediaId(null);
|
||||
setActiveMenuType(null);
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -162,7 +228,10 @@ export const CarrierCreatePage = observer(() => {
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
|
||||
isLoading ||
|
||||
!createCarrierData[language].full_name ||
|
||||
!createCarrierData[language].short_name ||
|
||||
!createCarrierData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -177,12 +246,14 @@ export const CarrierCreatePage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={3}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createCarrierData[language].full_name}
|
||||
contextType="carrier"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
@@ -14,11 +14,11 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CarrierEditPage = observer(() => {
|
||||
@@ -32,8 +32,9 @@ export const CarrierEditPage = observer(() => {
|
||||
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" | null
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,6 +73,9 @@ export const CarrierEditPage = observer(() => {
|
||||
|
||||
mediaStore.getMedia();
|
||||
})();
|
||||
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, [id]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
@@ -141,7 +145,7 @@ export const CarrierEditPage = observer(() => {
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities[language].data?.map((city) => (
|
||||
{cityStore.cities["ru"].data?.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -209,23 +213,15 @@ export const CarrierEditPage = observer(() => {
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -238,15 +234,13 @@ export const CarrierEditPage = observer(() => {
|
||||
disabled={
|
||||
isLoading ||
|
||||
!editCarrierData[language].full_name ||
|
||||
!editCarrierData[language].short_name ||
|
||||
!editCarrierData.city_id ||
|
||||
!editCarrierData.logo
|
||||
!editCarrierData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -255,12 +249,14 @@ export const CarrierEditPage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={3}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editCarrierData[language].full_name}
|
||||
contextType="carrier"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
@@ -270,6 +266,23 @@ export const CarrierEditPage = observer(() => {
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteLogoModalOpen}
|
||||
onDelete={() => {
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setIsDeleteLogoModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteLogoModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { carrierStore, languageStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, cityStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CarrierListPage = observer(() => {
|
||||
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
||||
const { getCities, cities } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
await getCarriers(language);
|
||||
})();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -55,14 +65,17 @@ export const CarrierListPage = observer(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city",
|
||||
field: "city_id",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const city = cities[language]?.data.find(
|
||||
(city) => city.id == params.value
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
{city && city.name ? (
|
||||
city.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
@@ -75,6 +88,7 @@ export const CarrierListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -103,7 +117,7 @@ export const CarrierListPage = observer(() => {
|
||||
id: carrier.id,
|
||||
full_name: carrier.full_name,
|
||||
short_name: carrier.short_name,
|
||||
city: carrier.city,
|
||||
city_id: carrier.city_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -131,12 +145,24 @@ export const CarrierListPage = observer(() => {
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
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>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save, Minus } from "lucide-react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -31,7 +31,7 @@ export const CityCreatePage = observer(() => {
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | null
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia } = mediaStore;
|
||||
@@ -132,15 +132,9 @@ export const CityCreatePage = observer(() => {
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
{!selectedMedia && (
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<Minus size={20} />
|
||||
<span className="text-sm">Герб города не выбран</span>
|
||||
</div>
|
||||
)}
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="thumbnail"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
@@ -156,12 +150,15 @@ export const CityCreatePage = observer(() => {
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -185,14 +182,18 @@ export const CityCreatePage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={3} // Тип медиа для иконок
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createCityData[language]?.name}
|
||||
contextType="city"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
hardcodeType={
|
||||
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
|
||||
}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
|
||||
@@ -35,7 +35,7 @@ export const CityEditPage = observer(() => {
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | null
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
@@ -43,6 +43,11 @@ export const CityEditPage = observer(() => {
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia, getOneMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -58,6 +63,7 @@ export const CityEditPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
await getCountries("ru");
|
||||
// Fetch data for all languages
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
@@ -69,7 +75,7 @@ export const CityEditPage = observer(() => {
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
await getCountries("ru");
|
||||
|
||||
await getMedia();
|
||||
}
|
||||
})();
|
||||
@@ -151,7 +157,7 @@ export const CityEditPage = observer(() => {
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="thumbnail"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
@@ -167,12 +173,15 @@ export const CityEditPage = observer(() => {
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("thumbnail");
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -189,7 +198,7 @@ export const CityEditPage = observer(() => {
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -198,14 +207,23 @@ export const CityEditPage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={3} // Тип медиа для иконок
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editCityData[language].name}
|
||||
contextType="city"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
hardcodeType={
|
||||
activeMenuType as
|
||||
| "thumbnail"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
| null
|
||||
}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, cityStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, cityStore, countryStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { toast } from "react-toastify";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CityListPage = observer(() => {
|
||||
const { cities, getCities, deleteCity } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getCities(language);
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await countryStore.getCountries("ru");
|
||||
await countryStore.getCountries("en");
|
||||
await countryStore.getCountries("zh");
|
||||
await getCities(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
let newRows = cities[language]?.data?.map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
country: city.country_code,
|
||||
}));
|
||||
|
||||
let newRows2: any[] = [];
|
||||
for (const city of newRows) {
|
||||
const name = countryStore.countries[language]?.data?.find(
|
||||
(country) => country.code === city.country
|
||||
)?.name;
|
||||
if (name) {
|
||||
newRows2.push(city);
|
||||
}
|
||||
}
|
||||
|
||||
setRows(newRows2 || []);
|
||||
}, [cities, countryStore.countries, language, isLoading]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "country",
|
||||
@@ -27,7 +61,9 @@ export const CityListPage = observer(() => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
countryStore.countries[language]?.data?.find(
|
||||
(country) => country.code === params.value
|
||||
)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
@@ -57,18 +93,19 @@ export const CityListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
@@ -81,12 +118,6 @@ export const CityListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = cities[language]?.data?.map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
country: city.country,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
@@ -96,11 +127,37 @@ export const CityListPage = observer(() => {
|
||||
<h1 className="text-2xl">Города</h1>
|
||||
<CreateButton label="Создать город" path="/city/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,6 +176,20 @@ export const CityListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteCity(id.toString())));
|
||||
toast.success("Города успешно удалены");
|
||||
getCities(language);
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
115
src/pages/Country/CountryAddPage/index.tsx
Normal file
115
src/pages/Country/CountryAddPage/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
FormControl,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
countryStore,
|
||||
RU_COUNTRIES,
|
||||
EN_COUNTRIES,
|
||||
ZH_COUNTRIES,
|
||||
} from "@shared";
|
||||
import { useState } from "react";
|
||||
|
||||
export const CountryAddPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||
|
||||
const handleCountryCodeChange = (code: string) => {
|
||||
const ruCountry = RU_COUNTRIES.find((c) => c.code === code);
|
||||
const enCountry = EN_COUNTRIES.find((c) => c.code === code);
|
||||
const zhCountry = ZH_COUNTRIES.find((c) => c.code === code);
|
||||
|
||||
if (ruCountry && enCountry && zhCountry) {
|
||||
setCountryData(code, ruCountry.name, "ru");
|
||||
setCountryData(code, enCountry.name, "en");
|
||||
setCountryData(code, zhCountry.name, "zh");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createCountry();
|
||||
toast.success("Страна успешно создана");
|
||||
navigate("/country");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании страны");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<FormControl fullWidth>
|
||||
<Autocomplete
|
||||
value={
|
||||
RU_COUNTRIES.find((c) => c.code === createCountryData.code) ||
|
||||
null
|
||||
}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue) {
|
||||
handleCountryCodeChange(newValue.code);
|
||||
}
|
||||
}}
|
||||
options={RU_COUNTRIES}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.name}`}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Страна"
|
||||
required
|
||||
inputProps={{
|
||||
...params.inputProps,
|
||||
maxLength: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchValue = inputValue.toUpperCase();
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.code.includes(searchValue) ||
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !createCountryData.code}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => {
|
||||
const { editCountryData, editCountry, getCountry, setEditCountryData } =
|
||||
countryStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -88,7 +93,7 @@ export const CountryEditPage = observer(() => {
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Trash2, Minus } from "lucide-react";
|
||||
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CountryListPage = observer(() => {
|
||||
const { countries, getCountries, deleteCountry } = countryStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getCountries(language);
|
||||
const fetchCountries = async () => {
|
||||
setIsLoading(true);
|
||||
await getCountries(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchCountries();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -40,19 +50,21 @@ export const CountryListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button
|
||||
{/* <button
|
||||
onClick={() => navigate(`/country/${params.row.code}/edit`)}
|
||||
>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
</button> */}
|
||||
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.code);
|
||||
}}
|
||||
@@ -78,16 +90,47 @@ export const CountryListPage = observer(() => {
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Страны</h1>
|
||||
<CreateButton label="Создать страну" path="/country/create" />
|
||||
<CreateButton label="Добавить страну" path="/country/add" />
|
||||
</div>
|
||||
<DataGrid rows={rows} columns={columns} hideFooter />
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows || []}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (!rowId) return;
|
||||
await deleteCountry(rowId, language);
|
||||
await deleteCountry(rowId);
|
||||
setRowId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
}}
|
||||
@@ -96,6 +139,19 @@ export const CountryListPage = observer(() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteCountry(id.toString())));
|
||||
getCountries(language);
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./CountryListPage";
|
||||
export * from "./CountryPreviewPage";
|
||||
export * from "./CountryCreatePage";
|
||||
export * from "./CountryEditPage";
|
||||
export * from "./CountryAddPage";
|
||||
|
||||
@@ -19,7 +19,7 @@ export const EditSightPage = observer(() => {
|
||||
const { getArticles } = articlesStore;
|
||||
|
||||
const { id } = useParams();
|
||||
const { getRuCities } = cityStore;
|
||||
const { getCities } = cityStore;
|
||||
|
||||
let blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
@@ -33,13 +33,13 @@ export const EditSightPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
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 getRuCities();
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { authStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import { authStore, userStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -15,9 +18,21 @@ export const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = authStore;
|
||||
const { getUsers } = userStore;
|
||||
useEffect(() => {
|
||||
// Load saved credentials if they exist
|
||||
const savedEmail = localStorage.getItem("rememberedEmail");
|
||||
const savedPassword = localStorage.getItem("rememberedPassword");
|
||||
if (savedEmail && savedPassword) {
|
||||
setEmail(savedEmail);
|
||||
setPassword(savedPassword);
|
||||
setRememberMe(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,7 +41,23 @@ export const LoginPage = () => {
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/sight");
|
||||
|
||||
// Save or clear credentials based on remember me checkbox
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("rememberedEmail", email);
|
||||
localStorage.setItem("rememberedPassword", password);
|
||||
} else {
|
||||
localStorage.removeItem("rememberedEmail");
|
||||
localStorage.removeItem("rememberedPassword");
|
||||
}
|
||||
|
||||
navigate("/map");
|
||||
try {
|
||||
await getUsers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
toast.success("Вход в систему выполнен успешно");
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -47,73 +78,102 @@ export const LoginPage = () => {
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
gap: 3,
|
||||
p: 3,
|
||||
backgroundImage: "url('/login-bg.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Вход в систему
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
p: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "white",
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
className="text-center pb-[50px]"
|
||||
>
|
||||
Вход в систему
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
label="Запомнить пароль"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,7 @@ class MapStore {
|
||||
|
||||
getRoutes = async () => {
|
||||
const response = await languageInstance("ru").get("/route");
|
||||
console.log(response.data);
|
||||
|
||||
const routesIds = response.data.map((route: any) => route.id);
|
||||
for (const id of routesIds) {
|
||||
const route = await languageInstance("ru").get(`/route/${id}`);
|
||||
@@ -116,7 +116,6 @@ class MapStore {
|
||||
const updatedStations: any[] = [];
|
||||
|
||||
const parsedJSON = JSON.parse(json);
|
||||
console.log("Данные для сохранения (GeoJSON):", parsedJSON);
|
||||
|
||||
for (const feature of parsedJSON.features) {
|
||||
const { geometry, properties, id } = feature;
|
||||
@@ -211,13 +210,6 @@ class MapStore {
|
||||
|
||||
const requests: Promise<any>[] = [];
|
||||
|
||||
console.log(
|
||||
`К созданию: ${newStations.length} станций, ${newRoutes.length} маршрутов, ${newSights.length} достопримечательностей.`
|
||||
);
|
||||
console.log(
|
||||
`К обновлению: ${updatedStations.length} станций, ${updatedRoutes.length} маршрутов, ${updatedSights.length} достопримечательностей.`
|
||||
);
|
||||
|
||||
newStations.forEach((data) =>
|
||||
requests.push(languageInstance("ru").post("/station", data))
|
||||
);
|
||||
@@ -239,13 +231,11 @@ class MapStore {
|
||||
);
|
||||
|
||||
if (requests.length === 0) {
|
||||
console.log("Нет изменений для сохранения.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(requests);
|
||||
console.log("Все изменения успешно сохранены!");
|
||||
|
||||
await Promise.all([
|
||||
this.getRoutes(),
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import { Save, ArrowLeft } from "lucide-react";
|
||||
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
|
||||
export const MediaEditPage = observer(() => {
|
||||
@@ -40,10 +45,10 @@ export const MediaEditPage = observer(() => {
|
||||
if (id) {
|
||||
mediaStore.getOneMedia(id);
|
||||
}
|
||||
console.log(newFile);
|
||||
console.log(uploadDialogOpen);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {}, [newFile, uploadDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (media) {
|
||||
setMediaName(media.media_name);
|
||||
@@ -55,7 +60,11 @@ export const MediaEditPage = observer(() => {
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]); // 3D model
|
||||
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||
} else if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]); // Video
|
||||
@@ -64,6 +73,11 @@ export const MediaEditPage = observer(() => {
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
@@ -99,7 +113,11 @@ export const MediaEditPage = observer(() => {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]); // 3D model
|
||||
setMediaType(6);
|
||||
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||
} else if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||
setMediaType(1); // Default to Photo
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { DeleteModal } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const MediaListPage = observer(() => {
|
||||
const { media, getMedia, deleteMedia } = mediaStore;
|
||||
@@ -13,10 +15,16 @@ export const MediaListPage = observer(() => {
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getMedia();
|
||||
const fetchMedia = async () => {
|
||||
setIsLoading(true);
|
||||
await getMedia();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchMedia();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -61,6 +69,7 @@ export const MediaListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -91,11 +100,6 @@ export const MediaListPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Медиа</h1>
|
||||
<CreateButton label="Создать медиа" path="/media/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
@@ -114,10 +118,19 @@ export const MediaListPage = observer(() => {
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as string[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,30 +15,32 @@ export const MediaPreviewPage = observer(() => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<MediaViewer className="w-full h-full" media={oneMedia!} />
|
||||
</div>
|
||||
|
||||
{oneMedia && (
|
||||
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||
<p className="text-white text-center">
|
||||
Чтобы скачать файл, нажмите на кнопку ниже
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="flex justify-center items-center max-w-[60%]">
|
||||
<MediaViewer media={oneMedia!} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oneMedia && (
|
||||
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||
<p className="text-white text-center">
|
||||
Чтобы скачать файл, нажмите на кнопку ниже
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
608
src/pages/Route/LinekedStations.tsx
Normal file
608
src/pages/Route/LinekedStations.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
FormControl,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
IconButton,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import {
|
||||
authInstance,
|
||||
languageStore,
|
||||
routeStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||||
|
||||
// Helper function to insert an item at a specific position (1-based index)
|
||||
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||
const index = pos - 1;
|
||||
const result = [...arr];
|
||||
if (index >= result.length) {
|
||||
result.push(value);
|
||||
} else {
|
||||
result.splice(index, 0, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper function to reorder items after drag and drop
|
||||
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedItemsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
dragAllowed?: boolean;
|
||||
onUpdate?: () => void;
|
||||
dontRecurse?: boolean;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
routeDirection?: boolean;
|
||||
};
|
||||
|
||||
export const LinkedItems = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedItemsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные станции
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedItemsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedItemsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
dragAllowed = false,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
routeDirection,
|
||||
}: LinkedItemsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [position, setPosition] = useState<number>(1);
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "route";
|
||||
const childResource = "station";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
// Если направление маршрута не указано, показываем все станции
|
||||
if (routeDirection === undefined) return true;
|
||||
// Фильтруем станции по направлению маршрута
|
||||
return item.direction === routeDirection;
|
||||
})
|
||||
.filter((item) => {
|
||||
// Фильтруем по городу из навбара
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Фильтрация по поиску для массового режима
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(linkedItems.length + 1);
|
||||
}, [linkedItems.length]);
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const reorderedItems = reorder(
|
||||
linkedItems,
|
||||
result.source.index,
|
||||
result.destination.index
|
||||
);
|
||||
|
||||
setLinkedItems(reorderedItems);
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
stations: reorderedItems.map((item) => ({ id: item.id })),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating station order:", error);
|
||||
setError("Failed to update station order");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked items:", error);
|
||||
setError("Failed to load linked stations");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all items:", error);
|
||||
setError("Failed to load available stations");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
stations: insertAtPosition(
|
||||
linkedItems.map((item) => ({ id: item.id })),
|
||||
position,
|
||||
{ id: selectedItemId }
|
||||
),
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
const updatedList = insertAtPosition(
|
||||
[...linkedItems],
|
||||
position,
|
||||
newItem
|
||||
);
|
||||
setLinkedItems(updatedList);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking item:", error);
|
||||
setError("Failed to link station");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting item:", error);
|
||||
setError("Failed to delete station");
|
||||
});
|
||||
};
|
||||
|
||||
const handleStationClick = (item: T) => {
|
||||
routeStore.setSelectedStationId(item.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (itemId: number) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
const handleBulkLink = () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setError(null);
|
||||
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
|
||||
const requestData = {
|
||||
stations: [
|
||||
...linkedItems.map((item) => ({ id: item.id })),
|
||||
...selectedStations,
|
||||
],
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItems = allItems.filter((item) => selectedItems.has(item.id));
|
||||
setLinkedItems([...linkedItems, ...newItems]);
|
||||
setSelectedItems(new Set());
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking items:", error);
|
||||
setError("Failed to link stations");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell width="40px"></TableCell>
|
||||
)}
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>
|
||||
{field.label}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<Droppable
|
||||
droppableId="droppable-stations"
|
||||
isDropDisabled={type !== "edit" || !dragAllowed}
|
||||
>
|
||||
{(provided) => (
|
||||
<TableBody
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{linkedItems.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={"station-" + String(item.id)}
|
||||
index={index}
|
||||
isDragDisabled={type !== "edit" || !dragAllowed}
|
||||
>
|
||||
{(provided) => (
|
||||
<TableRow
|
||||
sx={{ cursor: "pointer" }}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
hover
|
||||
onClick={() => handleStationClick(item)}
|
||||
>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell {...provided.dragHandleProps}>
|
||||
<IconButton size="small">
|
||||
<DragIndicatorIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</TableBody>
|
||||
)}
|
||||
</Droppable>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Станции не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||
{routeDirection !== undefined && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Показываются только остановки для{" "}
|
||||
{routeDirection ? "прямого" : "обратного"} направления
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||
>
|
||||
<Tab label="По одной" />
|
||||
<Tab label="Массово" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{activeTab === 0 && (
|
||||
<Stack gap={2}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find(
|
||||
(item) => item.id === selectedItemId
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<p>{String(option.name)}</p>
|
||||
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
|
||||
{String(option.description)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Позиция добавляемой остановки"
|
||||
value={position}
|
||||
onChange={(e) => {
|
||||
const newValue = Math.max(1, Number(e.target.value));
|
||||
setPosition(
|
||||
newValue > linkedItems.length + 1
|
||||
? linkedItems.length + 1
|
||||
: newValue
|
||||
);
|
||||
}}
|
||||
InputProps={{
|
||||
inputProps: { min: 1, max: linkedItems.length + 1 },
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<Stack gap={2}>
|
||||
{/* Поле поиска */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Поиск остановок"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Введите название остановки..."
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
{/* Список доступных остановок с чекбоксами */}
|
||||
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||
<Stack gap={1}>
|
||||
{filteredAvailableItems.map((item) => (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => handleCheckboxChange(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{filteredAvailableItems.length === 0 && (
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
textAlign="center"
|
||||
py={1}
|
||||
>
|
||||
{searchQuery.trim()
|
||||
? "Остановки не найдены"
|
||||
: "Нет доступных остановок"}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleBulkLink}
|
||||
disabled={selectedItems.size === 0}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить выбранные ({selectedItems.size})
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedItemsContents = observer(
|
||||
LinkedItemsContentsInner
|
||||
) as typeof LinkedItemsContentsInner;
|
||||
@@ -6,19 +6,28 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
// Typography,
|
||||
Typography,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||
import { languageStore } from "@shared";
|
||||
import {
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -33,13 +42,90 @@ export const RouteCreatePage = observer(() => {
|
||||
const [turn, setTurn] = useState("");
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
articlesStore.getArticleList();
|
||||
}, [language]);
|
||||
|
||||
// Фильтруем перевозчиков только из выбранного города
|
||||
const filteredCarriers = useMemo(() => {
|
||||
const carriers =
|
||||
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||||
.data || [];
|
||||
|
||||
if (!selectedCityStore.selectedCityId) {
|
||||
return carriers;
|
||||
}
|
||||
|
||||
return carriers.filter(
|
||||
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
|
||||
);
|
||||
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
const coordinates = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
return "Введите хотя бы одну пару координат";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every(
|
||||
(point) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every((point) =>
|
||||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return "Неверный формат координат";
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
setGovernorAppeal(articleId.toString());
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setVideoPreview(media.id);
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
setIsVideoPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateRoute = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -52,16 +138,24 @@ export const RouteCreatePage = observer(() => {
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
const route_direction = direction === "forward";
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Координаты маршрута как массив массивов чисел
|
||||
const path = routeCoords
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
line
|
||||
.split(" ")
|
||||
.map((coord) => Number(coord.trim()))
|
||||
.filter((n) => !isNaN(n))
|
||||
)
|
||||
.filter((arr) => arr.length === 2);
|
||||
.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
// Собираем объект маршрута
|
||||
const newRoute: Partial<Route> = {
|
||||
@@ -80,6 +174,8 @@ export const RouteCreatePage = observer(() => {
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
path,
|
||||
video_preview:
|
||||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||
};
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
@@ -93,9 +189,13 @@ export const RouteCreatePage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === Number(governorAppeal)
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -114,16 +214,10 @@ export const RouteCreatePage = observer(() => {
|
||||
value={carrier}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) => setCarrier(e.target.value as string)}
|
||||
disabled={
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.length === 0
|
||||
}
|
||||
disabled={filteredCarriers.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.map((carrier) => (
|
||||
{filteredCarriers.map((carrier: any) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
@@ -141,9 +235,49 @@ export const RouteCreatePage = observer(() => {
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={3}
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
value={routeCoords}
|
||||
onChange={(e) => setRouteCoords(e.target.value)}
|
||||
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"
|
||||
@@ -152,24 +286,101 @@ export const RouteCreatePage = observer(() => {
|
||||
value={govRouteNumber}
|
||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Обращение губернатора</InputLabel>
|
||||
<Select
|
||||
value={governorAppeal}
|
||||
label="Обращение губернатора"
|
||||
onChange={(e) => setGovernorAppeal(e.target.value as string)}
|
||||
disabled={articlesStore.articleList.ru.data.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{articlesStore.articleList.ru.data.map(
|
||||
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{a.heading}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
videoPreview && videoPreview !== "" ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{videoPreview && videoPreview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVideoPreview("");
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-lg font-bold">
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
@@ -229,6 +440,49 @@ export const RouteCreatePage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: videoPreview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,37 +6,71 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
// Typography,
|
||||
Typography,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { routeStore } from "../../../shared/store/RouteStore";
|
||||
import {
|
||||
routeStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { languageStore } from "@shared";
|
||||
import { stationsStore } from "@shared";
|
||||
import { LinkedItems } from "../LinekedStations";
|
||||
|
||||
export const RouteEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { editRouteData } = routeStore;
|
||||
const { editRouteData, copyRouteAction } = routeStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
languageStore.setLanguage("ru");
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
carrierStore.getCarriers(language);
|
||||
stationsStore.getStations();
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
await routeStore.editRoute(Number(id));
|
||||
@@ -44,9 +78,80 @@ export const RouteEditPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
const coordinates = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
return "Введите хотя бы одну пару координат";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every(
|
||||
(point) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every((point) =>
|
||||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return "Неверный формат координат";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copyRouteAction(Number(id));
|
||||
toast.success("Маршрут успешно скопирован");
|
||||
};
|
||||
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
routeStore.setEditRouteData({
|
||||
governor_appeal: articleId,
|
||||
});
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||
setIsVideoPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === editRouteData.governor_appeal
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -105,15 +210,62 @@ export const RouteEditPage = observer(() => {
|
||||
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)),
|
||||
})
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const validationResult = validateCoordinates(newValue);
|
||||
if (validationResult === true) {
|
||||
const lines = newValue.trim().split("\n");
|
||||
const path = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
routeStore.setEditRouteData({ path });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const lines = coordinates.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = coordinates + "\n";
|
||||
setCoordinates(newValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
error={validateCoordinates(coordinates) !== true}
|
||||
helperText={
|
||||
typeof validateCoordinates(coordinates) === "string"
|
||||
? validateCoordinates(coordinates)
|
||||
: "Формат: широта долгота"
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "2px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
@@ -126,28 +278,111 @@ export const RouteEditPage = observer(() => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Обращение губернатора</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.governor_appeal || ""}
|
||||
label="Обращение губернатора"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
governor_appeal: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
disabled={articlesStore.articleList.ru.data.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{articlesStore.articleList.ru.data.map(
|
||||
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{a.heading}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-lg font-bold"
|
||||
>
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
@@ -166,55 +401,86 @@ export const RouteEditPage = observer(() => {
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
value={editRouteData.scale_min || ""}
|
||||
value={editRouteData.scale_min ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_min: Number(e.target.value),
|
||||
scale_min:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
value={editRouteData.scale_max || ""}
|
||||
value={editRouteData.scale_max ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_max: Number(e.target.value),
|
||||
scale_max:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
value={editRouteData.rotate || ""}
|
||||
value={editRouteData.rotate ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
rotate: Number(e.target.value),
|
||||
rotate:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. широта"
|
||||
value={editRouteData.center_latitude || ""}
|
||||
value={editRouteData.center_latitude ?? ""}
|
||||
type="text"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
center_latitude: Number(e.target.value),
|
||||
center_latitude: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. долгота"
|
||||
value={editRouteData.center_longitude || ""}
|
||||
value={editRouteData.center_longitude ?? ""}
|
||||
type="text"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
center_longitude: Number(e.target.value),
|
||||
center_longitude: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<div className="flex w-full justify-end">
|
||||
|
||||
<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}
|
||||
>
|
||||
Скопировать
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
@@ -227,6 +493,45 @@ export const RouteEditPage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, routeStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, routeStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const RouteListPage = observer(() => {
|
||||
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getRoutes();
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getCarriers("ru");
|
||||
await getCarriers("en");
|
||||
await getCarriers("zh");
|
||||
await getRoutes();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "carrier",
|
||||
field: "carrier_id",
|
||||
headerName: "Перевозчик",
|
||||
width: 250,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
carriers[language].data.find(
|
||||
(carrier) => carrier.id == params.value
|
||||
)?.short_name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
@@ -77,6 +90,7 @@ export const RouteListPage = observer(() => {
|
||||
width: 250,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
@@ -105,7 +119,7 @@ export const RouteListPage = observer(() => {
|
||||
|
||||
const rows = routes.data.map((route) => ({
|
||||
id: route.id,
|
||||
carrier: route.carrier,
|
||||
carrier_id: route.carrier_id,
|
||||
route_number: route.route_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
}));
|
||||
@@ -136,12 +150,20 @@ export const RouteListPage = observer(() => {
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const UP_SCALE = 30000;
|
||||
export const PATH_WIDTH = 15;
|
||||
export const STATION_RADIUS = 20;
|
||||
export const STATION_OUTLINE_WIDTH = 10;
|
||||
export const SIGHT_SIZE = 60;
|
||||
export const UP_SCALE = 10000;
|
||||
export const PATH_WIDTH = 5;
|
||||
export const STATION_RADIUS = 8;
|
||||
export const STATION_OUTLINE_WIDTH = 4;
|
||||
export const SIGHT_SIZE = 40;
|
||||
export const SCALE_FACTOR = 50;
|
||||
|
||||
export const BACKGROUND_COLOR = 0x111111;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function InfiniteCanvas({
|
||||
setScreenCenter,
|
||||
screenCenter,
|
||||
} = useTransform();
|
||||
const { routeData, originalRouteData } = useMapData();
|
||||
const { routeData, originalRouteData, setSelectedSight } = useMapData();
|
||||
|
||||
const applicationRef = useApplication();
|
||||
|
||||
@@ -45,6 +45,7 @@ export function InfiniteCanvas({
|
||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [startRotation, setStartRotation] = useState(0);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
|
||||
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
|
||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||
@@ -53,19 +54,20 @@ export function InfiniteCanvas({
|
||||
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = applicationRef?.app.canvas;
|
||||
if (!canvas) return;
|
||||
if (!applicationRef?.app?.canvas) return;
|
||||
|
||||
const canvas = applicationRef.app.canvas;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const canvasLeft = canvasRect.left;
|
||||
const canvasTop = canvasRect.top;
|
||||
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||
const centerY = window.innerHeight / 2 - canvasTop;
|
||||
setScreenCenter({ x: centerX, y: centerY });
|
||||
}, [applicationRef?.app.canvas, setScreenCenter]);
|
||||
}, [applicationRef?.app, setScreenCenter]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
@@ -93,7 +95,18 @@ export function InfiniteCanvas({
|
||||
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
||||
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
if (!isPointerDown) return;
|
||||
|
||||
// Проверяем, началось ли перетаскивание
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
@@ -136,6 +149,12 @@ export function InfiniteCanvas({
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
// Если не было перетаскивания, то это простой клик - закрываем виджет
|
||||
if (!isDragging) {
|
||||
setSelectedSight(undefined);
|
||||
}
|
||||
|
||||
setIsPointerDown(false);
|
||||
setIsDragging(false);
|
||||
// Сбрасываем флаг взаимодействия через небольшую задержку
|
||||
// чтобы избежать немедленного срабатывания useEffect
|
||||
@@ -185,7 +204,6 @@ export function InfiniteCanvas({
|
||||
|
||||
useEffect(() => {
|
||||
applicationRef?.app.render();
|
||||
console.log(position, scale, rotation);
|
||||
}, [position, scale, rotation]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { Stack, Typography, Button } from "@mui/material";
|
||||
|
||||
import { useNavigate, useNavigationType } from "react-router";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
export function LeftSidebar() {
|
||||
export const LeftSidebar = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||
const { routeData } = useMapData();
|
||||
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
async function fetchCarrierThumbnail() {
|
||||
if (routeData?.carrier_id) {
|
||||
const { city_id, logo } = (
|
||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||
).data;
|
||||
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||
setCarrierThumbnail(arms);
|
||||
setCarrierLogo(logo);
|
||||
}
|
||||
}
|
||||
fetchCarrierThumbnail();
|
||||
}, [routeData?.carrier_id]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (navigationType === "PUSH") {
|
||||
@@ -27,6 +47,7 @@ export function LeftSidebar() {
|
||||
color: "#fff",
|
||||
backgroundColor: "#222",
|
||||
borderRadius: 10,
|
||||
height: 40,
|
||||
width: "100%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
@@ -41,10 +62,30 @@ export function LeftSidebar() {
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
>
|
||||
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
|
||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства Санкт-Петербурга
|
||||
</Typography>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{carrierThumbnail && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства
|
||||
</Typography>{" "}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
@@ -65,15 +106,20 @@ export function LeftSidebar() {
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
maxHeight={150}
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
>
|
||||
<img
|
||||
src={"/GET.png"}
|
||||
alt="logo"
|
||||
width="80%"
|
||||
style={{ margin: "0 auto" }}
|
||||
/>
|
||||
{carrierLogo && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail_logo",
|
||||
}}
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
@@ -86,4 +132,4 @@ export function LeftSidebar() {
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,10 +29,13 @@ const MapDataContext = createContext<{
|
||||
isRouteLoading: boolean;
|
||||
isStationLoading: boolean;
|
||||
isSightLoading: boolean;
|
||||
selectedSight?: SightData;
|
||||
setSelectedSight: (sight?: SightData) => void;
|
||||
setScaleRange: (min: number, max: number) => void;
|
||||
setMapRotation: (rotation: number) => void;
|
||||
setMapCenter: (x: number, y: number) => void;
|
||||
setStationOffset: (stationId: number, x: number, y: number) => void;
|
||||
setStationAlign: (stationId: number, align: number) => void;
|
||||
setSightCoordinates: (
|
||||
sightId: number,
|
||||
latitude: number,
|
||||
@@ -50,10 +53,13 @@ const MapDataContext = createContext<{
|
||||
isRouteLoading: true,
|
||||
isStationLoading: true,
|
||||
isSightLoading: true,
|
||||
selectedSight: undefined,
|
||||
setSelectedSight: () => {},
|
||||
setScaleRange: () => {},
|
||||
setMapRotation: () => {},
|
||||
setMapCenter: () => {},
|
||||
setStationOffset: () => {},
|
||||
setStationAlign: () => {},
|
||||
setSightCoordinates: () => {},
|
||||
saveChanges: () => {},
|
||||
});
|
||||
@@ -87,6 +93,7 @@ export const MapDataProvider = observer(
|
||||
const [isRouteLoading, setIsRouteLoading] = useState(true);
|
||||
const [isStationLoading, setIsStationLoading] = useState(true);
|
||||
const [isSightLoading, setIsSightLoading] = useState(true);
|
||||
const [selectedSight, setSelectedSight] = useState<SightData>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -106,17 +113,18 @@ export const MapDataProvider = observer(
|
||||
languageInstance("ru").get(`/route/${routeId}/station`),
|
||||
languageInstance("en").get(`/route/${routeId}/station`),
|
||||
languageInstance("zh").get(`/route/${routeId}/station`),
|
||||
authInstance.get(`/route/${routeId}/sight`),
|
||||
languageInstance("ru").get(`/route/${routeId}/sight`),
|
||||
]);
|
||||
|
||||
setOriginalRouteData(routeResponse.data as RouteData);
|
||||
const routeData = routeResponse.data as RouteData;
|
||||
setOriginalRouteData(routeData);
|
||||
setOriginalStationData(ruStationResponse.data as StationData[]);
|
||||
setStationData({
|
||||
ru: ruStationResponse.data as StationData[],
|
||||
en: enStationResponse.data as StationData[],
|
||||
zh: zhStationResponse.data as StationData[],
|
||||
});
|
||||
setOriginalSightData(sightResponse as unknown as SightData[]);
|
||||
setOriginalSightData(sightResponse.data as SightData[]);
|
||||
|
||||
setIsRouteLoading(false);
|
||||
setIsStationLoading(false);
|
||||
@@ -176,43 +184,136 @@ export const MapDataProvider = observer(
|
||||
}
|
||||
|
||||
async function saveSightChanges() {
|
||||
console.log("sightChanges", sightChanges);
|
||||
for (const sight of sightChanges) {
|
||||
await authInstance.patch(`/route/${routeId}/sight`, sight);
|
||||
}
|
||||
}
|
||||
|
||||
function setStationOffset(stationId: number, x: number, y: number) {
|
||||
setStationChanges((prev) => {
|
||||
let found = prev.find((station) => station.station_id === stationId);
|
||||
if (found) {
|
||||
found.offset_x = x;
|
||||
found.offset_y = y;
|
||||
const currentStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
if (
|
||||
currentStation &&
|
||||
Math.abs(currentStation.offset_x - x) < 0.01 &&
|
||||
Math.abs(currentStation.offset_y - y) < 0.01
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return prev.map((station) => {
|
||||
if (station.station_id === stationId) {
|
||||
return found;
|
||||
setStationChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(station) => station.station_id === stationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const newChanges = [...prev];
|
||||
newChanges[existingIndex] = {
|
||||
...newChanges[existingIndex],
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
};
|
||||
return newChanges;
|
||||
} else {
|
||||
const originalStation = originalStationData?.find(
|
||||
(s) => s.id === stationId
|
||||
);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
align: originalStation?.align ?? 1,
|
||||
transfers: originalStation?.transfers ?? {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
setStationData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((lang) => {
|
||||
updated[lang] = updated[lang].map((station) => {
|
||||
if (station.id === stationId) {
|
||||
return { ...station, offset_x: x, offset_y: y };
|
||||
}
|
||||
return station;
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function setStationAlign(stationId: number, align: number) {
|
||||
const currentStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
if (currentStation && currentStation.align === align) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStationChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(station) => station.station_id === stationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const newChanges = [...prev];
|
||||
newChanges[existingIndex] = {
|
||||
...newChanges[existingIndex],
|
||||
align: align,
|
||||
};
|
||||
return newChanges;
|
||||
} else {
|
||||
const foundStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
const originalStation = originalStationData?.find(
|
||||
(s) => s.id === stationId
|
||||
);
|
||||
if (foundStation) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
transfers: foundStation.transfers,
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
align: align,
|
||||
offset_x: originalStation?.offset_x ?? 0,
|
||||
offset_y: originalStation?.offset_y ?? 0,
|
||||
transfers: originalStation?.transfers ?? {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
setStationData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((lang) => {
|
||||
updated[lang] = updated[lang].map((station) => {
|
||||
if (station.id === stationId) {
|
||||
return { ...station, align: align };
|
||||
}
|
||||
return station;
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function setSightCoordinates(
|
||||
@@ -221,14 +322,18 @@ export const MapDataProvider = observer(
|
||||
longitude: number
|
||||
) {
|
||||
setSightChanges((prev) => {
|
||||
let found = prev.find((sight) => sight.sight_id === sightId);
|
||||
if (found) {
|
||||
found.latitude = latitude;
|
||||
found.longitude = longitude;
|
||||
const existingIndex = prev.findIndex(
|
||||
(sight) => sight.sight_id === sightId
|
||||
);
|
||||
|
||||
return prev.map((sight) => {
|
||||
if (sight.sight_id === sightId) {
|
||||
return found;
|
||||
if (existingIndex !== -1) {
|
||||
return prev.map((sight, index) => {
|
||||
if (index === existingIndex) {
|
||||
return {
|
||||
...sight,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
return sight;
|
||||
});
|
||||
@@ -249,9 +354,7 @@ export const MapDataProvider = observer(
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log("sightChanges", sightChanges);
|
||||
}, [sightChanges]);
|
||||
useEffect(() => {}, [sightChanges]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
@@ -264,11 +367,14 @@ export const MapDataProvider = observer(
|
||||
isRouteLoading,
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
setSelectedSight,
|
||||
setScaleRange,
|
||||
setMapRotation,
|
||||
setMapCenter,
|
||||
saveChanges,
|
||||
setStationOffset,
|
||||
setStationAlign,
|
||||
setSightCoordinates,
|
||||
}),
|
||||
[
|
||||
@@ -281,6 +387,7 @@ export const MapDataProvider = observer(
|
||||
isRouteLoading,
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||
import { SCALE_FACTOR } from "./Constants";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export function RightSidebar() {
|
||||
const {
|
||||
@@ -20,19 +22,31 @@ export function RightSidebar() {
|
||||
screenCenter,
|
||||
rotateToAngle,
|
||||
setTransform,
|
||||
scale,
|
||||
setScaleAtCenter,
|
||||
} = useTransform();
|
||||
|
||||
const [minScale, setMinScale] = useState<number>(1);
|
||||
const [maxScale, setMaxScale] = useState<number>(10);
|
||||
const [maxScale, setMaxScale] = useState<number>(5);
|
||||
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
|
||||
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData) {
|
||||
setMinScale(originalRouteData.scale_min ?? 1);
|
||||
setMaxScale(originalRouteData.scale_max ?? 10);
|
||||
// Проверяем и сбрасываем минимальный масштаб если нужно
|
||||
const originalMinScale = originalRouteData.scale_min ?? 1;
|
||||
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
|
||||
|
||||
// Проверяем и сбрасываем максимальный масштаб если нужно
|
||||
const originalMaxScale = originalRouteData.scale_max ?? 5;
|
||||
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
|
||||
|
||||
setMinScale(resetMinScale);
|
||||
setMaxScale(resetMaxScale);
|
||||
setRotationDegrees(originalRouteData.rotate ?? 0);
|
||||
setLocalCenter({
|
||||
x: originalRouteData.center_latitude ?? 0,
|
||||
@@ -52,16 +66,26 @@ export function RightSidebar() {
|
||||
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
|
||||
);
|
||||
}, [rotation]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapRotation(rotationDegrees);
|
||||
}, [rotationDegrees]);
|
||||
|
||||
useEffect(() => {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const localCenter = screenToLocal(center.x, center.y);
|
||||
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
|
||||
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
|
||||
}, [position]);
|
||||
if (!isUserEditing) {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const localCenter = screenToLocal(center.x, center.y);
|
||||
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
|
||||
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
|
||||
}
|
||||
}, [
|
||||
position,
|
||||
screenCenter,
|
||||
screenToLocal,
|
||||
localToCoordinates,
|
||||
setLocalCenter,
|
||||
isUserEditing,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapCenter(localCenter.x, localCenter.y);
|
||||
@@ -77,7 +101,6 @@ export function RightSidebar() {
|
||||
}
|
||||
|
||||
if (!routeData) {
|
||||
console.error("routeData is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -104,7 +127,30 @@ export function RightSidebar() {
|
||||
label="Минимальный масштаб"
|
||||
variant="filled"
|
||||
value={minScale}
|
||||
onChange={(e) => setMinScale(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
let newMinScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
}
|
||||
|
||||
setMinScale(newMinScale);
|
||||
|
||||
if (maxScale - newMinScale < 2) {
|
||||
let newMaxScale = newMinScale + 2;
|
||||
// Сбрасываем максимальный к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
setMinScale(1); // Сбрасываем минимальный к 1
|
||||
}
|
||||
setMaxScale(newMaxScale);
|
||||
}
|
||||
|
||||
if (newMinScale > scale * SCALE_FACTOR) {
|
||||
setScaleAtCenter(newMinScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -116,7 +162,8 @@ export function RightSidebar() {
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0.1,
|
||||
min: 1,
|
||||
max: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -125,7 +172,30 @@ export function RightSidebar() {
|
||||
label="Максимальный масштаб"
|
||||
variant="filled"
|
||||
value={maxScale}
|
||||
onChange={(e) => setMaxScale(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
let newMaxScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
}
|
||||
|
||||
setMaxScale(newMaxScale);
|
||||
|
||||
if (newMaxScale - minScale < 2) {
|
||||
let newMinScale = newMaxScale - 2;
|
||||
// Сбрасываем минимальный к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
|
||||
}
|
||||
setMinScale(newMinScale);
|
||||
}
|
||||
|
||||
if (newMaxScale < scale * SCALE_FACTOR) {
|
||||
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -137,12 +207,71 @@ export function RightSidebar() {
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0.1,
|
||||
min: 3,
|
||||
max: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
value={scale * SCALE_FACTOR}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
setScaleAtCenter(newValue / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
min={minScale}
|
||||
max={maxScale}
|
||||
step={0.1}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Текущий масштаб"
|
||||
variant="filled"
|
||||
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||
onChange={(e) => {
|
||||
const newScale = Number(e.target.value);
|
||||
if (
|
||||
!isNaN(newScale) &&
|
||||
newScale >= minScale &&
|
||||
newScale <= maxScale
|
||||
) {
|
||||
setScaleAtCenter(newScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: minScale,
|
||||
max: maxScale,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Поворот (в градусах)"
|
||||
@@ -181,11 +310,13 @@ export function RightSidebar() {
|
||||
type="number"
|
||||
label="Центр карты, широта"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.x * 100000) / 100000}
|
||||
value={Math.round(localCenter.x * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
|
||||
pan({ x: Number(e.target.value), y: localCenter.y });
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -195,16 +326,21 @@ export function RightSidebar() {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
step: 0.001,
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Центр карты, высота"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.y * 100000) / 100000}
|
||||
value={Math.round(localCenter.y * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
|
||||
pan({ x: localCenter.x, y: Number(e.target.value) });
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -214,6 +350,9 @@ export function RightSidebar() {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
step: 0.001,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -221,8 +360,14 @@ export function RightSidebar() {
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => {
|
||||
saveChanges();
|
||||
onClick={async () => {
|
||||
try {
|
||||
await saveChanges();
|
||||
toast.success("Изменения сохранены");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при сохранении изменений");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить изменения
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { SightData } from "./types";
|
||||
import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js";
|
||||
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
|
||||
|
||||
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||
@@ -12,19 +12,21 @@ interface SightProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setSightCoordinates } = useMapData();
|
||||
const { setSightCoordinates, setSelectedSight } = useMapData();
|
||||
|
||||
const [position, setPosition] = useState(
|
||||
coordinatesToLocal(sight.latitude, sight.longitude)
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -37,7 +39,18 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
if (!isPointerDown) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
|
||||
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
|
||||
const cos = Math.cos(rotation);
|
||||
@@ -53,30 +66,33 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(false);
|
||||
|
||||
// Если не было перетаскивания, то это клик
|
||||
if (!isDragging) {
|
||||
setSelectedSight(sight);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||
useEffect(() => {
|
||||
if (texture === Texture.EMPTY) {
|
||||
Assets.load("/SightIcon.png").then((result) => {
|
||||
setTexture(result);
|
||||
});
|
||||
}
|
||||
}, [texture]);
|
||||
Assets.load("/SightIcon.png").then(setTexture);
|
||||
}, []);
|
||||
|
||||
function draw(g: Graphics) {
|
||||
g.clear();
|
||||
g.circle(0, 0, 20);
|
||||
g.fill({ color: "#000" }); // Fill circle with primary color
|
||||
}
|
||||
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
|
||||
|
||||
if (!sight) {
|
||||
console.error("sight is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Компенсируем масштаб для сохранения постоянного размера
|
||||
const compensatedSize = SIGHT_SIZE / scale;
|
||||
const compensatedFontSize = 24 / scale;
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
rotation={-rotation}
|
||||
@@ -86,22 +102,34 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
x={position.x * UP_SCALE - SIGHT_SIZE / 2} // Offset by half width to center
|
||||
y={position.y * UP_SCALE - SIGHT_SIZE / 2} // Offset by half height to center
|
||||
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
|
||||
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
|
||||
>
|
||||
<pixiSprite texture={texture} width={SIGHT_SIZE} height={SIGHT_SIZE} />
|
||||
<pixiGraphics draw={draw} x={SIGHT_SIZE} y={0} />
|
||||
<pixiSprite
|
||||
texture={texture}
|
||||
width={compensatedSize}
|
||||
height={compensatedSize}
|
||||
/>
|
||||
<pixiGraphics
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
g.circle(0, 0, 20 / scale);
|
||||
g.fill({ color: "#000" });
|
||||
}}
|
||||
x={compensatedSize}
|
||||
y={0}
|
||||
/>
|
||||
<pixiText
|
||||
text={`${id + 1}`}
|
||||
x={SIGHT_SIZE + 1}
|
||||
x={compensatedSize + 1 / scale}
|
||||
y={0}
|
||||
anchor={0.5}
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontSize: compensatedFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
60
src/pages/Route/route-preview/SightInfoWidget.tsx
Normal file
60
src/pages/Route/route-preview/SightInfoWidget.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Typography, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
export function SightInfoWidget() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
|
||||
if (!selectedSight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
color: "white",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "4px",
|
||||
minWidth: 250,
|
||||
maxWidth: 400,
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
zIndex: 1000,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}>
|
||||
{selectedSight.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedSight(undefined)}
|
||||
sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}>
|
||||
{selectedSight.address}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "#999" }}>
|
||||
Город: {selectedSight.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
||||
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
|
||||
import {
|
||||
BACKGROUND_COLOR,
|
||||
PATH_COLOR,
|
||||
@@ -7,140 +11,565 @@ import {
|
||||
UP_SCALE,
|
||||
} from "./Constants";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { useCallback, useState } from "react";
|
||||
import { StationData } from "./types";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore } from "@shared";
|
||||
// --- Конец заглушек ---
|
||||
|
||||
// --- Декларации для react-pixi ---
|
||||
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
|
||||
declare const pixiContainer: any;
|
||||
declare const pixiGraphics: any;
|
||||
declare const pixiText: any;
|
||||
|
||||
// --- Типы ---
|
||||
type HorizontalAlign = "left" | "center" | "right";
|
||||
type VerticalAlign = "top" | "center" | "bottom";
|
||||
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
||||
type LabelAlign = "left" | "center" | "right";
|
||||
|
||||
// --- Утилиты ---
|
||||
|
||||
/**
|
||||
* Преобразует текстовое позиционирование в anchor координаты.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Получает координату anchor.x из типа выравнивания.
|
||||
*/
|
||||
|
||||
// --- Интерфейсы пропсов ---
|
||||
|
||||
interface StationProps {
|
||||
station: StationData;
|
||||
ruLabel: string | null;
|
||||
anchorPoint?: { x: number; y: number };
|
||||
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
|
||||
labelBlockAnchor?: TextAlign | { x: number; y: number };
|
||||
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
|
||||
labelAlign?: LabelAlign;
|
||||
/** Callback для изменения внутреннего выравнивания */
|
||||
onLabelAlignChange?: (align: LabelAlign) => void;
|
||||
/** Callback для отслеживания наведения на текст */
|
||||
onTextHover?: (isHovered: boolean) => void;
|
||||
}
|
||||
|
||||
export const Station = observer(
|
||||
({ station, ruLabel }: Readonly<StationProps>) => {
|
||||
const draw = useCallback((g: Graphics) => {
|
||||
interface LabelAlignmentControlProps {
|
||||
scale: number;
|
||||
currentAlign: LabelAlign;
|
||||
onAlignChange: (align: LabelAlign) => void;
|
||||
onPointerOver: () => void;
|
||||
onPointerOut: () => void;
|
||||
onControlPointerEnter: () => void;
|
||||
onControlPointerLeave: () => void;
|
||||
}
|
||||
|
||||
interface StationLabelProps
|
||||
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
|
||||
|
||||
const getAnchorFromOffset = (
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
): { x: number; y: number } => {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
return { x: 0.5, y: 0.5 };
|
||||
}
|
||||
|
||||
const length = Math.hypot(offsetX, offsetY);
|
||||
const nx = offsetX / length;
|
||||
const ny = offsetY / length;
|
||||
|
||||
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Панель управления выравниванием в стиле УрФУ
|
||||
// =========================================================================
|
||||
|
||||
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
scale,
|
||||
currentAlign,
|
||||
onAlignChange,
|
||||
|
||||
onControlPointerEnter,
|
||||
onControlPointerLeave,
|
||||
}) => {
|
||||
const controlHeight = 50 / scale;
|
||||
const controlWidth = 200 / scale;
|
||||
const fontSize = 18 / scale;
|
||||
const borderRadius = 8 / scale;
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const buttonWidth = controlWidth / 3;
|
||||
const strokeWidth = 2 / scale;
|
||||
|
||||
const drawBg = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
|
||||
// Основной фон с градиентом
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
controlWidth,
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
|
||||
|
||||
// Тонкая рамка
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
controlWidth,
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
|
||||
// Разделители между кнопками
|
||||
for (let i = 1; i < 3; i++) {
|
||||
const x = -controlWidth / 2 + buttonWidth * i;
|
||||
g.moveTo(x, strokeWidth);
|
||||
g.lineTo(x, controlHeight - strokeWidth);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
|
||||
);
|
||||
|
||||
const drawButtonHighlight = useCallback(
|
||||
(g: Graphics, index: number, isActive: boolean) => {
|
||||
g.clear();
|
||||
|
||||
if (isActive) {
|
||||
const x = -controlWidth / 2 + buttonWidth * index;
|
||||
g.roundRect(
|
||||
x + strokeWidth,
|
||||
strokeWidth,
|
||||
buttonWidth - strokeWidth * 2,
|
||||
controlHeight - strokeWidth * 2,
|
||||
borderRadius / 2
|
||||
);
|
||||
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||
);
|
||||
|
||||
const getTextStyle = (isActive: boolean) => ({
|
||||
fontSize,
|
||||
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
|
||||
fill: isActive ? "#ffffff" : "#cccccc",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
});
|
||||
|
||||
const alignOptions = [
|
||||
{ key: "left" as const, label: "Left" },
|
||||
{ key: "center" as const, label: "Center" },
|
||||
{ key: "right" as const, label: "Right" },
|
||||
];
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
|
||||
zIndex={999999999999999999}
|
||||
eventMode="static"
|
||||
onPointerOver={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerEnter();
|
||||
}}
|
||||
onPointerOut={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerLeave();
|
||||
}}
|
||||
onPointerDown={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Основной фон */}
|
||||
<pixiGraphics draw={drawBg} />
|
||||
|
||||
{/* Кнопки с подсветкой */}
|
||||
{alignOptions.map((option, index) => (
|
||||
<pixiContainer key={option.key}>
|
||||
{/* Подсветка активной кнопки */}
|
||||
<pixiGraphics
|
||||
draw={(g: Graphics) =>
|
||||
drawButtonHighlight(g, index, option.key === currentAlign)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Текст кнопки */}
|
||||
<pixiText
|
||||
text={option.label}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
position={{
|
||||
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
|
||||
y: controlHeight / 2,
|
||||
}}
|
||||
style={getTextStyle(option.key === currentAlign)}
|
||||
eventMode="static"
|
||||
cursor="pointer"
|
||||
onClick={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAlignChange(option.key);
|
||||
}}
|
||||
onPointerDown={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAlignChange(option.key);
|
||||
}}
|
||||
onPointerOver={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerEnter();
|
||||
}}
|
||||
/>
|
||||
</pixiContainer>
|
||||
))}
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Метка Станции (с логикой)
|
||||
// =========================================================================
|
||||
|
||||
const StationLabel = observer(
|
||||
({
|
||||
station,
|
||||
ruLabel,
|
||||
|
||||
labelAlign: labelAlignProp = "center",
|
||||
onLabelAlignChange,
|
||||
onTextHover,
|
||||
}: Readonly<StationLabelProps>) => {
|
||||
const { language } = languageStore;
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setStationOffset, setStationAlign } = useMapData();
|
||||
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isControlHovered, setIsControlHovered] = useState(false);
|
||||
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
|
||||
const [ruLabelWidth, setRuLabelWidth] = useState(0);
|
||||
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const mouseStartPos = useRef({ x: 0, y: 0 });
|
||||
const hideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const ruLabelRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text is hovered
|
||||
};
|
||||
|
||||
const handleControlPointerEnter = () => {
|
||||
// Дополнительная обработка для панели управления
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsControlHovered(true);
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text/control is hovered
|
||||
};
|
||||
|
||||
const handleControlPointerLeave = () => {
|
||||
setIsControlHovered(false);
|
||||
// Если курсор не над основным контейнером, скрываем панель через некоторое время
|
||||
if (!isHovered) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
// Увеличиваем время до скрытия панели и добавляем проверку
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
// Если курсор не над панелью управления, скрываем и её
|
||||
if (!isControlHovered) {
|
||||
setIsControlHovered(false);
|
||||
}
|
||||
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
|
||||
}, 100); // Увеличиваем время до скрытия панели
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||
}, [station.offset_x, station.offset_y, station.id]);
|
||||
|
||||
// Функция для конвертации числового align в строковый
|
||||
const convertNumericAlign = (align: number): LabelAlign => {
|
||||
switch (align) {
|
||||
case 0:
|
||||
return "left";
|
||||
case 1:
|
||||
return "center";
|
||||
case 2:
|
||||
return "right";
|
||||
default:
|
||||
return "center";
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для конвертации строкового align в числовой
|
||||
const convertStringAlign = (align: LabelAlign): number => {
|
||||
switch (align) {
|
||||
case "left":
|
||||
return 0;
|
||||
case "center":
|
||||
return 1;
|
||||
case "right":
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
|
||||
}, [station.align]);
|
||||
|
||||
if (!station) return null;
|
||||
|
||||
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const compensatedNameFontSize = (16 * 0.75) / scale;
|
||||
|
||||
// Измеряем ширину верхнего лейбла
|
||||
useEffect(() => {
|
||||
if (ruLabelRef.current && ruLabel) {
|
||||
setRuLabelWidth(ruLabelRef.current.width);
|
||||
}
|
||||
}, [ruLabel, compensatedRuFontSize]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
dragStartPos.current = { ...position };
|
||||
mouseStartPos.current = { x: e.global.x, y: e.global.y };
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isPointerDown) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.global.x - mouseStartPos.current.x;
|
||||
const dy = e.global.y - mouseStartPos.current.y;
|
||||
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
|
||||
else return;
|
||||
}
|
||||
|
||||
const dx_screen = e.global.x - mouseStartPos.current.x;
|
||||
const dy_screen = e.global.y - mouseStartPos.current.y;
|
||||
|
||||
const newPosition = {
|
||||
x: dragStartPos.current.x + dx_screen,
|
||||
y: dragStartPos.current.y + dy_screen,
|
||||
};
|
||||
|
||||
// Проверяем, изменилась ли позиция
|
||||
if (
|
||||
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||
Math.abs(newPosition.y - position.y) > 0.01
|
||||
) {
|
||||
setPosition(newPosition);
|
||||
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(false);
|
||||
setTimeout(() => setIsDragging(false), 50);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleAlignChange = async (align: LabelAlign) => {
|
||||
setCurrentLabelAlign(align);
|
||||
onLabelAlignChange?.(align);
|
||||
// Сохраняем в стор
|
||||
const numericAlign = convertStringAlign(align);
|
||||
setStationAlign(station.id, numericAlign);
|
||||
};
|
||||
|
||||
const dynamicAnchor = useMemo(
|
||||
() => getAnchorFromOffset(position.x, position.y),
|
||||
[position.x, position.y]
|
||||
);
|
||||
|
||||
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
|
||||
const getSecondLabelPosition = (): number => {
|
||||
if (!ruLabelWidth) return 0;
|
||||
|
||||
switch (currentLabelAlign) {
|
||||
case "left":
|
||||
// Позиционируем относительно левого края верхнего текста
|
||||
return -ruLabelWidth / 2;
|
||||
case "center":
|
||||
// Центрируем относительно центра верхнего текста
|
||||
return 0;
|
||||
case "right":
|
||||
// Позиционируем относительно правого края верхнего текста
|
||||
return ruLabelWidth / 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для расчета anchor нижнего лейбла
|
||||
const getSecondLabelAnchor = (): number => {
|
||||
switch (currentLabelAlign) {
|
||||
case "left":
|
||||
return 0; // anchor.x = 0 (левый край)
|
||||
case "center":
|
||||
return 0.5; // anchor.x = 0.5 (центр)
|
||||
case "right":
|
||||
return 1; // anchor.x = 1 (правый край)
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
x={coordinates.x * UP_SCALE}
|
||||
y={coordinates.y * UP_SCALE}
|
||||
rotation={-rotation}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
eventMode="static"
|
||||
interactive
|
||||
cursor={isDragging ? "grabbing" : "grab"}
|
||||
onPointerOver={handlePointerEnter}
|
||||
onPointerOut={handlePointerLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
>
|
||||
<pixiContainer
|
||||
position={{
|
||||
x:
|
||||
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||||
scale,
|
||||
y:
|
||||
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||||
scale,
|
||||
}}
|
||||
anchor={dynamicAnchor}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
>
|
||||
{ruLabel && (
|
||||
<pixiText
|
||||
ref={ruLabelRef}
|
||||
text={ruLabel}
|
||||
position={{ x: 0, y: 0 }}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
style={{
|
||||
fontSize: compensatedRuFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{station.name && language !== "ru" && ruLabel && (
|
||||
<pixiText
|
||||
text={station.name}
|
||||
position={{
|
||||
x: getSecondLabelPosition(),
|
||||
y: compensatedRuFontSize * 1.1,
|
||||
}}
|
||||
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
|
||||
style={{
|
||||
fontSize: compensatedNameFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#CCCCCC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(isHovered || isControlHovered) && !isDragging && (
|
||||
<LabelAlignmentControl
|
||||
scale={scale}
|
||||
currentAlign={currentLabelAlign}
|
||||
onAlignChange={handleAlignChange}
|
||||
onPointerOver={handlePointerEnter}
|
||||
onPointerOut={handlePointerLeave}
|
||||
onControlPointerEnter={handleControlPointerEnter}
|
||||
onControlPointerLeave={handleControlPointerLeave}
|
||||
/>
|
||||
)}
|
||||
</pixiContainer>
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Главный экспортируемый компонент: Станция
|
||||
// =========================================================================
|
||||
|
||||
export const Station = ({
|
||||
station,
|
||||
ruLabel,
|
||||
|
||||
labelAlign,
|
||||
onLabelAlignChange,
|
||||
}: Readonly<StationProps>) => {
|
||||
const [isTextHovered, setIsTextHovered] = useState(false);
|
||||
|
||||
const draw = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
const coordinates = coordinatesToLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
g.circle(
|
||||
coordinates.x * UP_SCALE,
|
||||
coordinates.y * UP_SCALE,
|
||||
STATION_RADIUS
|
||||
);
|
||||
g.fill({ color: PATH_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pixiContainer>
|
||||
<pixiGraphics draw={draw} />
|
||||
<StationLabel station={station} ruLabel={ruLabel} />
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
const radius = STATION_RADIUS;
|
||||
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||||
|
||||
export const StationLabel = observer(
|
||||
({ station, ruLabel }: Readonly<StationProps>) => {
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setStationOffset } = useMapData();
|
||||
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||
|
||||
const [position, setPosition] = useState({
|
||||
x: station.offset_x,
|
||||
y: station.offset_y,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [startMousePosition, setStartMousePosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
// Change fill color when text is hovered
|
||||
if (isTextHovered) {
|
||||
g.fill({ color: 0x00aaff }); // Highlight color when hovered
|
||||
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
|
||||
} else {
|
||||
g.fill({ color: PATH_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||
}
|
||||
},
|
||||
[station.latitude, station.longitude, isTextHovered]
|
||||
);
|
||||
|
||||
if (!station) {
|
||||
console.error("station is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
setStartMousePosition({
|
||||
x: e.globalX,
|
||||
y: e.globalY,
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
const newPosition = {
|
||||
x: startPosition.x + dx,
|
||||
y: startPosition.y + dy,
|
||||
};
|
||||
setPosition(newPosition);
|
||||
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(false);
|
||||
e.stopPropagation();
|
||||
};
|
||||
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
eventMode="static"
|
||||
interactive
|
||||
onPointerDown={handlePointerDown}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
width={48}
|
||||
height={48}
|
||||
x={coordinates.x * UP_SCALE}
|
||||
y={coordinates.y * UP_SCALE}
|
||||
rotation={-rotation}
|
||||
>
|
||||
<pixiText
|
||||
anchor={{ x: 1, y: 0.5 }}
|
||||
text={station.name}
|
||||
position={{
|
||||
x: position.x / scale + 24,
|
||||
y: position.y / scale,
|
||||
}}
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
|
||||
{ruLabel && (
|
||||
<pixiText
|
||||
anchor={{ x: 1, y: -1 }}
|
||||
text={ruLabel}
|
||||
position={{
|
||||
x: position.x / scale + 24,
|
||||
y: position.y / scale,
|
||||
}}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
fill: "#CCCCCC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||||
<pixiGraphics draw={draw} />
|
||||
<StationLabel
|
||||
station={station}
|
||||
ruLabel={ruLabel}
|
||||
labelAlign={labelAlign}
|
||||
onLabelAlignChange={onLabelAlignChange}
|
||||
onTextHover={setIsTextHovered}
|
||||
/>
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,9 +26,12 @@ const TransformContext = createContext<{
|
||||
rotationDegrees?: number,
|
||||
scale?: number
|
||||
) => void;
|
||||
setScaleOnly: (newScale: number) => void;
|
||||
setScaleWithoutMovingCenter: (newScale: number) => void;
|
||||
setScreenCenter: React.Dispatch<
|
||||
React.SetStateAction<{ x: number; y: number } | undefined>
|
||||
>;
|
||||
setScaleAtCenter: (newScale: number) => void;
|
||||
}>({
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
@@ -41,7 +44,10 @@ const TransformContext = createContext<{
|
||||
localToScreen: () => ({ x: 0, y: 0 }),
|
||||
rotateToAngle: () => {},
|
||||
setTransform: () => {},
|
||||
setScaleOnly: () => {},
|
||||
setScaleWithoutMovingCenter: () => {},
|
||||
setScreenCenter: () => {},
|
||||
setScaleAtCenter: () => {},
|
||||
});
|
||||
|
||||
// Provider component
|
||||
@@ -136,8 +142,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
|
||||
console.log("center", center.x, center.y);
|
||||
|
||||
const newPosition = {
|
||||
x: -latitude * UP_SCALE * selectedScale,
|
||||
y: -longitude * UP_SCALE * selectedScale,
|
||||
@@ -160,6 +164,37 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
[rotation, scale, screenCenter]
|
||||
);
|
||||
|
||||
const setScaleAtCenter = useCallback(
|
||||
(newScale: number) => {
|
||||
if (scale === newScale) return;
|
||||
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
|
||||
const actualZoomFactor = newScale / scale;
|
||||
|
||||
const newPosition = {
|
||||
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
|
||||
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
|
||||
};
|
||||
|
||||
setPosition(newPosition);
|
||||
setScale(newScale);
|
||||
},
|
||||
[position, scale, screenCenter]
|
||||
);
|
||||
|
||||
const setScaleOnly = useCallback((newScale: number) => {
|
||||
// Изменяем только масштаб, не трогая позицию и поворот
|
||||
setScale(newScale);
|
||||
}, []);
|
||||
|
||||
const setScaleWithoutMovingCenter = useCallback(
|
||||
(newScale: number) => {
|
||||
setScale(newScale);
|
||||
},
|
||||
[setScale]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
position,
|
||||
@@ -173,17 +208,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
screenToLocal,
|
||||
localToScreen,
|
||||
setTransform,
|
||||
setScaleOnly,
|
||||
setScaleWithoutMovingCenter,
|
||||
setScreenCenter,
|
||||
setScaleAtCenter,
|
||||
}),
|
||||
[
|
||||
position,
|
||||
scale,
|
||||
rotation,
|
||||
screenCenter,
|
||||
setScale,
|
||||
rotateToAngle,
|
||||
screenToLocal,
|
||||
localToScreen,
|
||||
setTransform,
|
||||
setScaleOnly,
|
||||
setScaleWithoutMovingCenter,
|
||||
setScreenCenter,
|
||||
setScaleAtCenter,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { Stack, Typography, Box, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { Landmark } from "lucide-react";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
export function Widgets() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
@@ -24,6 +29,8 @@ export function Widgets() {
|
||||
Станция
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={223}
|
||||
@@ -31,12 +38,102 @@ export function Widgets() {
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Погода
|
||||
</Typography>
|
||||
{selectedSight ? (
|
||||
<Box
|
||||
sx={{ height: "100%", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* Заголовок с кнопкой закрытия */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 0.5 }}>
|
||||
<Landmark size={16} className="shrink-0" />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: "#fff", fontWeight: "bold" }}
|
||||
>
|
||||
{selectedSight.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedSight(undefined)}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
p: 0,
|
||||
minWidth: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
|
||||
}}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Описание достопримечательности */}
|
||||
{selectedSight.address && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#fff",
|
||||
mb: 1,
|
||||
opacity: 0.9,
|
||||
lineHeight: 1.3,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
}}
|
||||
>
|
||||
{selectedSight.address}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Город */}
|
||||
{selectedSight.city && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#fff",
|
||||
opacity: 0.7,
|
||||
mt: "auto",
|
||||
}}
|
||||
>
|
||||
Город: {selectedSight.city}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Landmark size={32} />
|
||||
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
|
||||
Выберите достопримечательность
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
|
||||
import { Widgets } from "./Widgets";
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import {
|
||||
Container,
|
||||
@@ -14,16 +14,19 @@ import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||
import { TransformProvider, useTransform } from "./TransformContext";
|
||||
import { InfiniteCanvas } from "./InfiniteCanvas";
|
||||
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import { Station } from "./Station";
|
||||
import { TravelPath } from "./TravelPath";
|
||||
import { LeftSidebar } from "./LeftSidebar";
|
||||
import { RightSidebar } from "./RightSidebar";
|
||||
import { Widgets } from "./Widgets";
|
||||
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Sight } from "./Sight";
|
||||
import { SightData } from "./types";
|
||||
import { Station } from "./Station";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
extend({
|
||||
Container,
|
||||
@@ -34,17 +37,31 @@ extend({
|
||||
Text,
|
||||
});
|
||||
|
||||
const Loading = () => {
|
||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||
|
||||
if (isRouteLoading || isStationLoading || isSightLoading) {
|
||||
return (
|
||||
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
export const RoutePreview = () => {
|
||||
const { routeData, stationData, sightData } = useMapData();
|
||||
return (
|
||||
<MapDataProvider>
|
||||
<TransformProvider>
|
||||
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
|
||||
<LanguageSwitcher />
|
||||
|
||||
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
|
||||
<Loading />
|
||||
<LeftSidebar />
|
||||
<Stack direction="row" flex={1} position="relative" height="100%">
|
||||
<Widgets />
|
||||
<RouteMap />
|
||||
<Widgets />
|
||||
<RightSidebar />
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -55,15 +72,27 @@ export const RoutePreview = () => {
|
||||
|
||||
export const RouteMap = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const { setPosition, screenToLocal, setTransform, screenCenter } =
|
||||
useTransform();
|
||||
const { routeData, stationData, sightData, originalRouteData } = useMapData();
|
||||
console.log(stationData);
|
||||
const { setPosition, setTransform, screenCenter } = useTransform();
|
||||
const {
|
||||
routeData,
|
||||
stationData,
|
||||
sightData,
|
||||
originalRouteData,
|
||||
originalSightData,
|
||||
} = useMapData();
|
||||
|
||||
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
||||
const [isSetup, setIsSetup] = useState(false);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData) {
|
||||
const path = originalRouteData?.path;
|
||||
@@ -131,13 +160,13 @@ export const RouteMap = observer(() => {
|
||||
]);
|
||||
|
||||
if (!routeData || !stationData || !sightData) {
|
||||
console.error("routeData, stationData or sightData is null");
|
||||
return <div>Loading...</div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||
<Application resizeTo={parentRef} background="#fff">
|
||||
<LanguageSwitcher />
|
||||
<Application resizeTo={parentRef} background="#fff" preference="webgl">
|
||||
<InfiniteCanvas>
|
||||
<TravelPath points={points} />
|
||||
{stationData[language].map((obj, index) => (
|
||||
@@ -146,20 +175,14 @@ export const RouteMap = observer(() => {
|
||||
key={obj.id}
|
||||
ruLabel={
|
||||
language === "ru"
|
||||
? stationData.en[index].name
|
||||
? stationData.ru[index].name
|
||||
: stationData.ru[index].name
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<pixiGraphics
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
const localCenter = screenToLocal(0, 0);
|
||||
g.circle(localCenter.x, localCenter.y, 10);
|
||||
g.fill("#fff");
|
||||
}}
|
||||
/>
|
||||
{originalSightData?.map((sight: SightData, index: number) => {
|
||||
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||
})}
|
||||
</InfiniteCanvas>
|
||||
</Application>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +1,72 @@
|
||||
export interface RouteData {
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
route_number: string;
|
||||
route_sys_number: string;
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
route_number: string;
|
||||
route_sys_number: string;
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
thumbnail?: string; // uuid логотипа маршрута
|
||||
}
|
||||
|
||||
export interface StationTransferData {
|
||||
bus: string;
|
||||
metro_blue: string;
|
||||
metro_green: string;
|
||||
metro_orange: string;
|
||||
metro_purple: string;
|
||||
metro_red: string;
|
||||
train: string;
|
||||
tram: string;
|
||||
trolleybus: string;
|
||||
bus: string;
|
||||
metro_blue: string;
|
||||
metro_green: string;
|
||||
metro_orange: string;
|
||||
metro_purple: string;
|
||||
metro_red: string;
|
||||
train: string;
|
||||
tram: string;
|
||||
trolleybus: string;
|
||||
}
|
||||
|
||||
export interface StationData {
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
system_name: string;
|
||||
transfers: StationTransferData;
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
system_name: string;
|
||||
transfers: StationTransferData;
|
||||
align: number;
|
||||
}
|
||||
|
||||
export interface StationPatchData {
|
||||
station_id: number;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
transfers: StationTransferData;
|
||||
station_id: number;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
align: number;
|
||||
transfers: StationTransferData;
|
||||
}
|
||||
|
||||
export interface SightPatchData {
|
||||
sight_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
sight_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface SightData {
|
||||
address: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
id: number;
|
||||
latitude: number;
|
||||
left_article: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
preview_media: number;
|
||||
thumbnail: string; // uuid
|
||||
watermark_lu: string; // uuid
|
||||
watermark_rd: string; // uuid
|
||||
}
|
||||
address: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
id: number;
|
||||
latitude: number;
|
||||
left_article: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
preview_media: number;
|
||||
thumbnail: string; // uuid
|
||||
watermark_lu: string; // uuid
|
||||
watermark_rd: string; // uuid
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, sightsStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
cityStore,
|
||||
languageStore,
|
||||
sightsStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const SightListPage = observer(() => {
|
||||
const { sights, getSights, deleteListSight } = sightsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getSights();
|
||||
const fetchSights = async () => {
|
||||
setIsLoading(true);
|
||||
await getCities(language);
|
||||
await getSights();
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSights();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -37,14 +53,14 @@ export const SightListPage = observer(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city",
|
||||
field: "city_id",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
cities[language].data.find((el) => el.id == params.value)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
@@ -57,6 +73,7 @@ export const SightListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -81,10 +98,19 @@ export const SightListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = sights.map((sight) => ({
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return sights;
|
||||
}
|
||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||
}, [sights, selectedCityStore.selectedCityId]);
|
||||
|
||||
const rows = filteredSights.map((sight) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
city: sight.city,
|
||||
city_id: sight.city_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -116,12 +142,24 @@ export const SightListPage = observer(() => {
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,69 +1,68 @@
|
||||
import { Button, Paper, TextField } from "@mui/material";
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { snapshotStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const SnapshotCreatePage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getSnapshot, createSnapshot } = snapshotStore;
|
||||
const { createSnapshot } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getSnapshot(id as string);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="w-full h-[400px] flex justify-center items-center">
|
||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
navigate(-1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании снапшота");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, snapshotStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
|
||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const SnapshotListPage = observer(() => {
|
||||
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||
@@ -14,9 +15,15 @@ export const SnapshotListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const { language } = languageStore;
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSnapshots();
|
||||
const fetchSnapshots = async () => {
|
||||
setIsLoading(true);
|
||||
await getSnapshots();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSnapshots();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -36,6 +43,7 @@ export const SnapshotListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
width: 300,
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -81,6 +89,15 @@ export const SnapshotListPage = observer(() => {
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,12 +118,15 @@ export const SnapshotListPage = observer(() => {
|
||||
|
||||
<SnapshotRestore
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
onDelete={async () => {
|
||||
setIsLoading(true);
|
||||
if (rowId) {
|
||||
await restoreSnapshot(rowId);
|
||||
}
|
||||
setIsRestoreModalOpen(false);
|
||||
setRowId(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsRestoreModalOpen(false);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./SnapshotListPage";
|
||||
|
||||
export * from "./SnapshotCreatePage";
|
||||
|
||||
328
src/pages/Station/LinkedSights.tsx
Normal file
328
src/pages/Station/LinkedSights.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedSightsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
onUpdate?: () => void;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
export const LinkedSights = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedSightsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные достопримечательности
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedSightsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedSightsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
}: LinkedSightsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "station";
|
||||
const childResource = "sight";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
// Фильтруем по городу из навбара
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
sight_id: selectedItemId,
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
setLinkedItems([...linkedItems, newItem]);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking sight:", error);
|
||||
setError("Failed to link sight");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting sight:", error);
|
||||
setError("Failed to delete sight");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked sights:", error);
|
||||
setError("Failed to load linked sights");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all sights:", error);
|
||||
setError("Failed to load available sights");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>{field.label}</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{linkedItems.map((item, index) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Достопримечательности не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">
|
||||
Добавить достопримечательность
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find((item) => item.id === selectedItemId) || null
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите достопримечательность"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
{String(option.name)}
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Загрузка...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" textAlign="center" py={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedSightsContents = observer(
|
||||
LinkedSightsContentsInner
|
||||
) as typeof LinkedSightsContentsInner;
|
||||
@@ -8,33 +8,107 @@ import {
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [systemName, setSystemName] = useState("");
|
||||
const [direction, setDirection] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
createStationData,
|
||||
setCreateCommonData,
|
||||
createStation,
|
||||
setLanguageCreateStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
useEffect(() => {
|
||||
if (
|
||||
createStationData.common.latitude !== 0 ||
|
||||
createStationData.common.longitude !== 0
|
||||
) {
|
||||
setCoordinates(
|
||||
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
|
||||
);
|
||||
}
|
||||
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
|
||||
const executeCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await stationsStore.createStation(name, systemName, direction);
|
||||
toast.success("Станция успешно создана");
|
||||
await createStation();
|
||||
toast.success("Остановка успешно создана");
|
||||
navigate("/station");
|
||||
} catch (error) {
|
||||
console.error("Error creating station:", error);
|
||||
toast.error("Ошибка при создании станции");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
|
||||
const handleCreate = async () => {
|
||||
const isCityMissing = !createStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmCreate = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelCreate = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCities = async () => {
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
};
|
||||
|
||||
fetchCities();
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||
setCreateCommonData({
|
||||
city_id: selectedCityId,
|
||||
city: selectedCity.name,
|
||||
});
|
||||
}
|
||||
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
@@ -47,44 +121,123 @@ export const StationCreatePage = observer(() => {
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{name}</h1>
|
||||
<h1 className="text-3xl break-words">Создание остановки</h1>
|
||||
</div>
|
||||
<TextField
|
||||
className="w-full"
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={createStationData[language].name || ""}
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Системное название"
|
||||
required
|
||||
value={systemName}
|
||||
onChange={(e) => setSystemName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setLanguageCreateStationData(language, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Направление</InputLabel>
|
||||
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={direction}
|
||||
label="Направление"
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
required
|
||||
labelId="direction-label"
|
||||
value={createStationData.common.direction ? "Прямой" : "Обратный"}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) =>
|
||||
setCreateCommonData({
|
||||
direction: e.target.value === "Прямой",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="forward">Прямое</MenuItem>
|
||||
<MenuItem value="backward">Обратное</MenuItem>
|
||||
<MenuItem value="Прямой">Прямой</MenuItem>
|
||||
<MenuItem value="Обратный">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Описание"
|
||||
value={createStationData.common.description || ""}
|
||||
onChange={(e) =>
|
||||
setCreateCommonData({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* <TextField
|
||||
fullWidth
|
||||
label="Адрес"
|
||||
value={createStationData[language].address || ""}
|
||||
onChange={(e) =>
|
||||
setLanguageCreateStationData(language, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/> */}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Координаты"
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const input = newValue.replace(/,/g, " ").trim();
|
||||
const [latStr, lonStr] = input.split(/\s+/);
|
||||
|
||||
const lat = parseFloat(latStr);
|
||||
const lon = parseFloat(lonStr);
|
||||
|
||||
const isValidLat = !isNaN(lat);
|
||||
const isValidLon = !isNaN(lon);
|
||||
|
||||
if (isValidLat && isValidLon) {
|
||||
setCreateCommonData({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
} else {
|
||||
setCreateCommonData({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={createStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setCreateCommonData({
|
||||
city_id: e.target.value as number,
|
||||
city: selectedCity?.name || "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !name || !systemName || !direction}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
@@ -93,6 +246,16 @@ export const StationCreatePage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmCreate,
|
||||
reset: handleCancelCreate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { toast } from "react-toastify";
|
||||
import { stationsStore, languageStore, cityStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -29,12 +31,31 @@ export const StationEditPage = observer(() => {
|
||||
setLanguageEditStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
const handleEdit = async () => {
|
||||
useEffect(() => {
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
editStationData.common.latitude !== 0 ||
|
||||
editStationData.common.longitude !== 0
|
||||
) {
|
||||
setCoordinates(
|
||||
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
|
||||
);
|
||||
}
|
||||
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
|
||||
const executeEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editStation(Number(id));
|
||||
toast.success("Станция успешно обновлена");
|
||||
toast.success("Остановка успешно обновлена");
|
||||
} catch (error) {
|
||||
console.error("Error updating station:", error);
|
||||
toast.error("Ошибка при обновлении станции");
|
||||
@@ -43,6 +64,34 @@ export const StationEditPage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
|
||||
const handleEdit = async () => {
|
||||
const isCityMissing = !editStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing =
|
||||
!editStationData.ru.name ||
|
||||
!editStationData.en.name ||
|
||||
!editStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmEdit = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelEdit = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAndSetStationData = async () => {
|
||||
if (!id) return;
|
||||
@@ -60,6 +109,7 @@ export const StationEditPage = observer(() => {
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -71,7 +121,7 @@ export const StationEditPage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<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
|
||||
@@ -106,15 +156,15 @@ export const StationEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Описание"
|
||||
value={editStationData[language].description || ""}
|
||||
value={editStationData.common.description || ""}
|
||||
onChange={(e) =>
|
||||
setLanguageEditStationData(language, {
|
||||
setEditCommonData({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{/* <TextField
|
||||
fullWidth
|
||||
label="Адрес"
|
||||
value={editStationData[language].address || ""}
|
||||
@@ -123,21 +173,38 @@ export const StationEditPage = observer(() => {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Координаты"
|
||||
value={`${editStationData.common.latitude} ${editStationData.common.longitude}`}
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const [latitude, longitude] = e.target.value.split(" ").map(Number);
|
||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const input = newValue.replace(/,/g, " ").trim();
|
||||
const [latStr, lonStr] = input.split(/\s+/);
|
||||
|
||||
const lat = parseFloat(latStr);
|
||||
const lon = parseFloat(lonStr);
|
||||
|
||||
const isValidLat = !isNaN(lat);
|
||||
const isValidLon = !isNaN(lon);
|
||||
|
||||
if (isValidLat && isValidLon) {
|
||||
setEditCommonData({
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
} else {
|
||||
setEditCommonData({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
@@ -146,7 +213,7 @@ export const StationEditPage = observer(() => {
|
||||
value={editStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities[language].data.find(
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setEditCommonData({
|
||||
@@ -155,7 +222,7 @@ export const StationEditPage = observer(() => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities[language].data.map((city) => (
|
||||
{cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -163,20 +230,38 @@ export const StationEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
fields={[{ label: "Название", data: "name" }]}
|
||||
type="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading || !editStationData[language]?.name}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmEdit,
|
||||
reset: handleCancelEdit,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
languageStore,
|
||||
stationsStore,
|
||||
selectedCityStore,
|
||||
cityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const StationListPage = observer(() => {
|
||||
const { stationLists, getStationList, deleteStation } = stationsStore;
|
||||
@@ -13,10 +20,17 @@ export const StationListPage = observer(() => {
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getStationList();
|
||||
const fetchStations = async () => {
|
||||
setIsLoading(true);
|
||||
await cityStore.getCities(language);
|
||||
await getStationList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchStations();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -77,6 +91,7 @@ export const StationListPage = observer(() => {
|
||||
width: 140,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -101,7 +116,18 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = stationLists[language].data.map((station: any) => ({
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return stationLists[language].data;
|
||||
}
|
||||
return stationLists[language].data.filter(
|
||||
(station: any) => station.city_id === selectedCityId
|
||||
);
|
||||
};
|
||||
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
system_name: station.system_name,
|
||||
@@ -115,7 +141,7 @@ export const StationListPage = observer(() => {
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Станции</h1>
|
||||
<CreateButton label="Создать станцию" path="/station/create" />
|
||||
<CreateButton label="Создать остановки" path="/station/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -136,10 +162,19 @@ export const StationListPage = observer(() => {
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
|
||||
export const StationPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
@@ -21,7 +22,7 @@ export const StationPreviewPage = observer(() => {
|
||||
}, [id, language]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
@@ -71,6 +72,17 @@ export const StationPreviewPage = observer(() => {
|
||||
<p>{stationPreview[id!]?.[language]?.data.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
fields={[
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Описание", data: "description" },
|
||||
]}
|
||||
type="show"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./StationListPage";
|
||||
export * from "./StationCreatePage";
|
||||
export * from "./StationPreviewPage";
|
||||
export * from "./StationEditPage";
|
||||
export * from "./LinkedSights";
|
||||
|
||||
@@ -10,15 +10,21 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore } from "@shared";
|
||||
import { userStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const UserEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { id } = useParams();
|
||||
const { editUserData, editUser, getUser, setEditUserData } = userStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -130,7 +136,7 @@ export const UserEditPage = observer(() => {
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { userStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const UserListPage = observer(() => {
|
||||
const { users, getUsers, deleteUser } = userStore;
|
||||
@@ -14,9 +15,15 @@ export const UserListPage = observer(() => {
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getUsers();
|
||||
const fetchUsers = async () => {
|
||||
setIsLoading(true);
|
||||
await getUsers();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -76,6 +83,7 @@ export const UserListPage = observer(() => {
|
||||
flex: 1,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -136,10 +144,23 @@ export const UserListPage = observer(() => {
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет пользователей"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export const VehicleEditPage = observer(() => {
|
||||
} = vehicleStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getVehicle(Number(id));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@@ -6,6 +7,7 @@ import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { VEHICLE_TYPES } from "@shared";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const VehicleListPage = observer(() => {
|
||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||
@@ -15,11 +17,17 @@ export const VehicleListPage = observer(() => {
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getVehicles();
|
||||
getCarriers(language);
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getVehicles();
|
||||
await getCarriers(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -94,6 +102,7 @@ export const VehicleListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -157,10 +166,23 @@ export const VehicleListPage = observer(() => {
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет транспортных средств"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const authInstance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
|
||||
|
||||
const languageInstance = (language: Language) => {
|
||||
const instance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||
|
||||
@@ -10,12 +10,11 @@ import {
|
||||
GitBranch,
|
||||
// Car,
|
||||
Table,
|
||||
Notebook,
|
||||
Split,
|
||||
Newspaper,
|
||||
// Newspaper,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
BookImage,
|
||||
// BookImage,
|
||||
} from "lucide-react";
|
||||
import { CarrierSvg } from "./CarrierSvg";
|
||||
|
||||
@@ -26,6 +25,7 @@ interface NavigationItem {
|
||||
label: string;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
isActive?: boolean;
|
||||
@@ -36,23 +36,56 @@ export const NAVIGATION_ITEMS: {
|
||||
secondary: NavigationItem[];
|
||||
} = {
|
||||
primary: [
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
label: "Карта",
|
||||
icon: Map,
|
||||
path: "/map",
|
||||
},
|
||||
{
|
||||
id: "devices",
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
for_admin: true,
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
label: "Все сущности",
|
||||
label: "Справочник",
|
||||
icon: Table,
|
||||
nestedItems: [
|
||||
{
|
||||
id: "media",
|
||||
label: "Медиа",
|
||||
icon: BookImage,
|
||||
path: "/media",
|
||||
},
|
||||
{
|
||||
id: "articles",
|
||||
label: "Статьи",
|
||||
icon: Newspaper,
|
||||
path: "/article",
|
||||
},
|
||||
// {
|
||||
// id: "media",
|
||||
// label: "Медиа",
|
||||
// icon: BookImage,
|
||||
// path: "/media",
|
||||
// },
|
||||
// {
|
||||
// id: "articles",
|
||||
// label: "Статьи",
|
||||
// icon: Newspaper,
|
||||
// path: "/article",
|
||||
// },
|
||||
{
|
||||
id: "attractions",
|
||||
label: "Достопримечательности",
|
||||
@@ -71,64 +104,31 @@ export const NAVIGATION_ITEMS: {
|
||||
icon: Split,
|
||||
path: "/route",
|
||||
},
|
||||
|
||||
{
|
||||
id: "reference",
|
||||
label: "Справочник",
|
||||
icon: Notebook,
|
||||
nestedItems: [
|
||||
{
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
path: "/carrier",
|
||||
},
|
||||
],
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
label: "Карта",
|
||||
icon: Map,
|
||||
path: "/map",
|
||||
},
|
||||
{
|
||||
id: "devices",
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
export const API_URL = import.meta.env.VITE_API_URL;
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -8,8 +8,10 @@ export const MEDIA_TYPE_LABELS = {
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
||||
export * from "./mediaTypes";
|
||||
|
||||
export const MEDIA_TYPE_VALUES = {
|
||||
photo: 1,
|
||||
image: 1,
|
||||
video: 2,
|
||||
icon: 3,
|
||||
thumbnail: 3,
|
||||
@@ -17,4 +19,754 @@ export const MEDIA_TYPE_VALUES = {
|
||||
watermark_rd: 4,
|
||||
panorama: 5,
|
||||
model: 6,
|
||||
video_preview: 2,
|
||||
};
|
||||
|
||||
export const RU_COUNTRIES = [
|
||||
{ code: "AF", name: "Афганистан" },
|
||||
{ code: "AX", name: "Аландские острова" },
|
||||
{ code: "AL", name: "Албания" },
|
||||
{ code: "DZ", name: "Алжир" },
|
||||
{ code: "AS", name: "Американское Самоа" },
|
||||
{ code: "AD", name: "Андорра" },
|
||||
{ code: "AO", name: "Ангола" },
|
||||
{ code: "AI", name: "Ангилья" },
|
||||
{ code: "AQ", name: "Антарктида" },
|
||||
{ code: "AG", name: "Антигуа и Барбуда" },
|
||||
{ code: "AR", name: "Аргентина" },
|
||||
{ code: "AM", name: "Армения" },
|
||||
{ code: "AW", name: "Аруба" },
|
||||
{ code: "AU", name: "Австралия" },
|
||||
{ code: "AT", name: "Австрия" },
|
||||
{ code: "AZ", name: "Азербайджан" },
|
||||
{ code: "BS", name: "Багамы" },
|
||||
{ code: "BH", name: "Бахрейн" },
|
||||
{ code: "BD", name: "Бангладеш" },
|
||||
{ code: "BB", name: "Барбадос" },
|
||||
{ code: "BY", name: "Беларусь" },
|
||||
{ code: "BE", name: "Бельгия" },
|
||||
{ code: "BZ", name: "Белиз" },
|
||||
{ code: "BJ", name: "Бенин" },
|
||||
{ code: "BM", name: "Бермуды" },
|
||||
{ code: "BT", name: "Бутан" },
|
||||
{ code: "BO", name: "Боливия" },
|
||||
{ code: "BA", name: "Босния и Герцеговина" },
|
||||
{ code: "BW", name: "Ботсвана" },
|
||||
{ code: "BV", name: "Остров Буве" },
|
||||
{ code: "BR", name: "Бразилия" },
|
||||
{ code: "IO", name: "Британская территория в Индийском океане" },
|
||||
{ code: "BN", name: "Бруней-Даруссалам" },
|
||||
{ code: "BG", name: "Болгария" },
|
||||
{ code: "BF", name: "Буркина-Фасо" },
|
||||
{ code: "BI", name: "Бурунди" },
|
||||
{ code: "KH", name: "Камбоджа" },
|
||||
{ code: "CM", name: "Камерун" },
|
||||
{ code: "CA", name: "Канада" },
|
||||
{ code: "CV", name: "Кабо-Верде" },
|
||||
{ code: "KY", name: "Каймановы острова" },
|
||||
{ code: "CF", name: "Центральноафриканская Республика" },
|
||||
{ code: "TD", name: "Чад" },
|
||||
{ code: "CL", name: "Чили" },
|
||||
{ code: "CN", name: "Китай" },
|
||||
{ code: "CX", name: "Остров Рождества" },
|
||||
{ code: "CC", name: "Кокосовые (Килинг) острова" },
|
||||
{ code: "CO", name: "Колумбия" },
|
||||
{ code: "KM", name: "Коморы" },
|
||||
{ code: "CG", name: "Конго" },
|
||||
{ code: "CD", name: "Демократическая Республика Конго" },
|
||||
{ code: "CK", name: "Острова Кука" },
|
||||
{ code: "CR", name: "Коста-Рика" },
|
||||
{ code: "CI", name: "Кот-д'Ивуар" },
|
||||
{ code: "HR", name: "Хорватия" },
|
||||
{ code: "CU", name: "Куба" },
|
||||
{ code: "CY", name: "Кипр" },
|
||||
{ code: "CZ", name: "Чехия" },
|
||||
{ code: "DK", name: "Дания" },
|
||||
{ code: "DJ", name: "Джибути" },
|
||||
{ code: "DM", name: "Доминика" },
|
||||
{ code: "DO", name: "Доминиканская Республика" },
|
||||
{ code: "EC", name: "Эквадор" },
|
||||
{ code: "EG", name: "Египет" },
|
||||
{ code: "SV", name: "Сальвадор" },
|
||||
{ code: "GQ", name: "Экваториальная Гвинея" },
|
||||
{ code: "ER", name: "Эритрея" },
|
||||
{ code: "EE", name: "Эстония" },
|
||||
{ code: "ET", name: "Эфиопия" },
|
||||
{ code: "FK", name: "Фолклендские острова (Мальвинские)" },
|
||||
{ code: "FO", name: "Фарерские острова" },
|
||||
{ code: "FJ", name: "Фиджи" },
|
||||
{ code: "FI", name: "Финляндия" },
|
||||
{ code: "FR", name: "Франция" },
|
||||
{ code: "GF", name: "Французская Гвиана" },
|
||||
{ code: "PF", name: "Французская Полинезия" },
|
||||
{ code: "TF", name: "Французские Южные территории" },
|
||||
{ code: "GA", name: "Габон" },
|
||||
{ code: "GM", name: "Гамбия" },
|
||||
{ code: "GE", name: "Грузия" },
|
||||
{ code: "DE", name: "Германия" },
|
||||
{ code: "GH", name: "Гана" },
|
||||
{ code: "GI", name: "Гибралтар" },
|
||||
{ code: "GR", name: "Греция" },
|
||||
{ code: "GL", name: "Гренландия" },
|
||||
{ code: "GD", name: "Гренада" },
|
||||
{ code: "GP", name: "Гваделупа" },
|
||||
{ code: "GU", name: "Гуам" },
|
||||
{ code: "GT", name: "Гватемала" },
|
||||
{ code: "GG", name: "Гернси" },
|
||||
{ code: "GN", name: "Гвинея" },
|
||||
{ code: "GW", name: "Гвинея-Бисау" },
|
||||
{ code: "GY", name: "Гайана" },
|
||||
{ code: "HT", name: "Гаити" },
|
||||
{ code: "HM", name: "Остров Херд и острова Макдональд" },
|
||||
{ code: "VA", name: "Ватикан" },
|
||||
{ code: "HN", name: "Гондурас" },
|
||||
{ code: "HK", name: "Гонконг" },
|
||||
{ code: "HU", name: "Венгрия" },
|
||||
{ code: "IS", name: "Исландия" },
|
||||
{ code: "IN", name: "Индия" },
|
||||
{ code: "ID", name: "Индонезия" },
|
||||
{ code: "IR", name: "Иран" },
|
||||
{ code: "IQ", name: "Ирак" },
|
||||
{ code: "IE", name: "Ирландия" },
|
||||
{ code: "IM", name: "Остров Мэн" },
|
||||
{ code: "IL", name: "Израиль" },
|
||||
{ code: "IT", name: "Италия" },
|
||||
{ code: "JM", name: "Ямайка" },
|
||||
{ code: "JP", name: "Япония" },
|
||||
{ code: "JE", name: "Джерси" },
|
||||
{ code: "JO", name: "Иордания" },
|
||||
{ code: "KZ", name: "Казахстан" },
|
||||
{ code: "KE", name: "Кения" },
|
||||
{ code: "KI", name: "Кирибати" },
|
||||
{ code: "KR", name: "Корея" },
|
||||
{ code: "KP", name: "Северная Корея" },
|
||||
{ code: "KW", name: "Кувейт" },
|
||||
{ code: "KG", name: "Киргизия" },
|
||||
{ code: "LA", name: "Лаос" },
|
||||
{ code: "LV", name: "Латвия" },
|
||||
{ code: "LB", name: "Ливан" },
|
||||
{ code: "LS", name: "Лесото" },
|
||||
{ code: "LR", name: "Либерия" },
|
||||
{ code: "LY", name: "Ливия" },
|
||||
{ code: "LI", name: "Лихтенштейн" },
|
||||
{ code: "LT", name: "Литва" },
|
||||
{ code: "LU", name: "Люксембург" },
|
||||
{ code: "MO", name: "Макао" },
|
||||
{ code: "MK", name: "Северная Македония" },
|
||||
{ code: "MG", name: "Мадагаскар" },
|
||||
{ code: "MW", name: "Малави" },
|
||||
{ code: "MY", name: "Малайзия" },
|
||||
{ code: "MV", name: "Мальдивы" },
|
||||
{ code: "ML", name: "Мали" },
|
||||
{ code: "MT", name: "Мальта" },
|
||||
{ code: "MH", name: "Маршалловы Острова" },
|
||||
{ code: "MQ", name: "Мартиника" },
|
||||
{ code: "MR", name: "Мавритания" },
|
||||
{ code: "MU", name: "Маврикий" },
|
||||
{ code: "YT", name: "Майотта" },
|
||||
{ code: "MX", name: "Мексика" },
|
||||
{ code: "FM", name: "Микронезия" },
|
||||
{ code: "MD", name: "Молдова" },
|
||||
{ code: "MC", name: "Монако" },
|
||||
{ code: "MN", name: "Монголия" },
|
||||
{ code: "ME", name: "Черногория" },
|
||||
{ code: "MS", name: "Монтсеррат" },
|
||||
{ code: "MA", name: "Марокко" },
|
||||
{ code: "MZ", name: "Мозамбик" },
|
||||
{ code: "MM", name: "Мьянма" },
|
||||
{ code: "NA", name: "Намибия" },
|
||||
{ code: "NR", name: "Науру" },
|
||||
{ code: "NP", name: "Непал" },
|
||||
{ code: "NL", name: "Нидерланды" },
|
||||
{ code: "AN", name: "Нидерландские Антильские острова" },
|
||||
{ code: "NC", name: "Новая Каледония" },
|
||||
{ code: "NZ", name: "Новая Зеландия" },
|
||||
{ code: "NI", name: "Никарагуа" },
|
||||
{ code: "NE", name: "Нигер" },
|
||||
{ code: "NG", name: "Нигерия" },
|
||||
{ code: "NU", name: "Ниуэ" },
|
||||
{ code: "NF", name: "Остров Норфолк" },
|
||||
{ code: "MP", name: "Северные Марианские острова" },
|
||||
{ code: "NO", name: "Норвегия" },
|
||||
{ code: "OM", name: "Оман" },
|
||||
{ code: "PK", name: "Пакистан" },
|
||||
{ code: "PW", name: "Палау" },
|
||||
{ code: "PS", name: "Палестинская территория" },
|
||||
{ code: "PA", name: "Панама" },
|
||||
{ code: "PG", name: "Папуа — Новая Гвинея" },
|
||||
{ code: "PY", name: "Парагвай" },
|
||||
{ code: "PE", name: "Перу" },
|
||||
{ code: "PH", name: "Филиппины" },
|
||||
{ code: "PN", name: "Питкэрн" },
|
||||
{ code: "PL", name: "Польша" },
|
||||
{ code: "PT", name: "Португалия" },
|
||||
{ code: "PR", name: "Пуэрто-Рико" },
|
||||
{ code: "QA", name: "Катар" },
|
||||
{ code: "RE", name: "Реюньон" },
|
||||
{ code: "RO", name: "Румыния" },
|
||||
{ code: "RU", name: "Россия" },
|
||||
{ code: "RW", name: "Руанда" },
|
||||
{ code: "BL", name: "Сен-Бартелеми" },
|
||||
{ code: "SH", name: "Остров Святой Елены" },
|
||||
{ code: "KN", name: "Сент-Китс и Невис" },
|
||||
{ code: "LC", name: "Сент-Люсия" },
|
||||
{ code: "MF", name: "Сен-Мартен" },
|
||||
{ code: "PM", name: "Сен-Пьер и Микелон" },
|
||||
{ code: "VC", name: "Сент-Винсент и Гренадины" },
|
||||
{ code: "WS", name: "Самоа" },
|
||||
{ code: "SM", name: "Сан-Марино" },
|
||||
{ code: "ST", name: "Сан-Томе и Принсипи" },
|
||||
{ code: "SA", name: "Саудовская Аравия" },
|
||||
{ code: "SN", name: "Сенегал" },
|
||||
{ code: "RS", name: "Сербия" },
|
||||
{ code: "SC", name: "Сейшельские Острова" },
|
||||
{ code: "SL", name: "Сьерра-Леоне" },
|
||||
{ code: "SG", name: "Сингапур" },
|
||||
{ code: "SK", name: "Словакия" },
|
||||
{ code: "SI", name: "Словения" },
|
||||
{ code: "SB", name: "Соломоновы Острова" },
|
||||
{ code: "SO", name: "Сомали" },
|
||||
{ code: "ZA", name: "Южная Африка" },
|
||||
{ code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" },
|
||||
{ code: "ES", name: "Испания" },
|
||||
{ code: "LK", name: "Шри-Ланка" },
|
||||
{ code: "SD", name: "Судан" },
|
||||
{ code: "SR", name: "Суринам" },
|
||||
{ code: "SJ", name: "Шпицберген и Ян-Майен" },
|
||||
{ code: "SZ", name: "Свазиленд" },
|
||||
{ code: "SE", name: "Швеция" },
|
||||
{ code: "CH", name: "Швейцария" },
|
||||
{ code: "SY", name: "Сирия" },
|
||||
{ code: "TW", name: "Тайвань" },
|
||||
{ code: "TJ", name: "Таджикистан" },
|
||||
{ code: "TZ", name: "Танзания" },
|
||||
{ code: "TH", name: "Таиланд" },
|
||||
{ code: "TL", name: "Восточный Тимор" },
|
||||
{ code: "TG", name: "Того" },
|
||||
{ code: "TK", name: "Токелау" },
|
||||
{ code: "TO", name: "Тонга" },
|
||||
{ code: "TT", name: "Тринидад и Тобаго" },
|
||||
{ code: "TN", name: "Тунис" },
|
||||
{ code: "TR", name: "Турция" },
|
||||
{ code: "TM", name: "Туркмения" },
|
||||
{ code: "TC", name: "Теркс и Кайкос" },
|
||||
{ code: "TV", name: "Тувалу" },
|
||||
{ code: "UG", name: "Уганда" },
|
||||
{ code: "UA", name: "Украина" },
|
||||
{ code: "AE", name: "Объединённые Арабские Эмираты" },
|
||||
{ code: "GB", name: "Великобритания" },
|
||||
{ code: "US", name: "США" },
|
||||
{ code: "UM", name: "Внешние малые острова США" },
|
||||
{ code: "UY", name: "Уругвай" },
|
||||
{ code: "UZ", name: "Узбекистан" },
|
||||
{ code: "VU", name: "Вануату" },
|
||||
{ code: "VE", name: "Венесуэла" },
|
||||
{ code: "VN", name: "Вьетнам" },
|
||||
{ code: "VG", name: "Британские Виргинские острова" },
|
||||
{ code: "VI", name: "Виргинские острова (США)" },
|
||||
{ code: "WF", name: "Уоллис и Футуна" },
|
||||
{ code: "EH", name: "Западная Сахара" },
|
||||
{ code: "YE", name: "Йемен" },
|
||||
{ code: "ZM", name: "Замбия" },
|
||||
{ code: "ZW", name: "Зимбабве" },
|
||||
];
|
||||
|
||||
// countries-en.js
|
||||
export const EN_COUNTRIES = [
|
||||
{ code: "AF", name: "Afghanistan" },
|
||||
{ code: "AX", name: "Aland Islands" },
|
||||
{ code: "AL", name: "Albania" },
|
||||
{ code: "DZ", name: "Algeria" },
|
||||
{ code: "AS", name: "American Samoa" },
|
||||
{ code: "AD", name: "Andorra" },
|
||||
{ code: "AO", name: "Angola" },
|
||||
{ code: "AI", name: "Anguilla" },
|
||||
{ code: "AQ", name: "Antarctica" },
|
||||
{ code: "AG", name: "Antigua And Barbuda" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "AM", name: "Armenia" },
|
||||
{ code: "AW", name: "Aruba" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "AZ", name: "Azerbaijan" },
|
||||
{ code: "BS", name: "Bahamas" },
|
||||
{ code: "BH", name: "Bahrain" },
|
||||
{ code: "BD", name: "Bangladesh" },
|
||||
{ code: "BB", name: "Barbados" },
|
||||
{ code: "BY", name: "Belarus" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "BZ", name: "Belize" },
|
||||
{ code: "BJ", name: "Benin" },
|
||||
{ code: "BM", name: "Bermuda" },
|
||||
{ code: "BT", name: "Bhutan" },
|
||||
{ code: "BO", name: "Bolivia" },
|
||||
{ code: "BA", name: "Bosnia And Herzegovina" },
|
||||
{ code: "BW", name: "Botswana" },
|
||||
{ code: "BV", name: "Bouvet Island" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "IO", name: "British Indian Ocean Territory" },
|
||||
{ code: "BN", name: "Brunei Darussalam" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "BF", name: "Burkina Faso" },
|
||||
{ code: "BI", name: "Burundi" },
|
||||
{ code: "KH", name: "Cambodia" },
|
||||
{ code: "CM", name: "Cameroon" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "CV", name: "Cape Verde" },
|
||||
{ code: "KY", name: "Cayman Islands" },
|
||||
{ code: "CF", name: "Central African Republic" },
|
||||
{ code: "TD", name: "Chad" },
|
||||
{ code: "CL", name: "Chile" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "CX", name: "Christmas Island" },
|
||||
{ code: "CC", name: "Cocos (Keeling) Islands" },
|
||||
{ code: "CO", name: "Colombia" },
|
||||
{ code: "KM", name: "Comoros" },
|
||||
{ code: "CG", name: "Congo" },
|
||||
{ code: "CD", name: "Congo, Democratic Republic" },
|
||||
{ code: "CK", name: "Cook Islands" },
|
||||
{ code: "CR", name: "Costa Rica" },
|
||||
{ code: "CI", name: "Cote D'Ivoire" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "CU", name: "Cuba" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "CZ", name: "Czech Republic" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "DJ", name: "Djibouti" },
|
||||
{ code: "DM", name: "Dominica" },
|
||||
{ code: "DO", name: "Dominican Republic" },
|
||||
{ code: "EC", name: "Ecuador" },
|
||||
{ code: "EG", name: "Egypt" },
|
||||
{ code: "SV", name: "El Salvador" },
|
||||
{ code: "GQ", name: "Equatorial Guinea" },
|
||||
{ code: "ER", name: "Eritrea" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "ET", name: "Ethiopia" },
|
||||
{ code: "FK", name: "Falkland Islands (Malvinas)" },
|
||||
{ code: "FO", name: "Faroe Islands" },
|
||||
{ code: "FJ", name: "Fiji" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "GF", name: "French Guiana" },
|
||||
{ code: "PF", name: "French Polynesia" },
|
||||
{ code: "TF", name: "French Southern Territories" },
|
||||
{ code: "GA", name: "Gabon" },
|
||||
{ code: "GM", name: "Gambia" },
|
||||
{ code: "GE", name: "Georgia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "GH", name: "Ghana" },
|
||||
{ code: "GI", name: "Gibraltar" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "GL", name: "Greenland" },
|
||||
{ code: "GD", name: "Grenada" },
|
||||
{ code: "GP", name: "Guadeloupe" },
|
||||
{ code: "GU", name: "Guam" },
|
||||
{ code: "GT", name: "Guatemala" },
|
||||
{ code: "GG", name: "Guernsey" },
|
||||
{ code: "GN", name: "Guinea" },
|
||||
{ code: "GW", name: "Guinea-Bissau" },
|
||||
{ code: "GY", name: "Guyana" },
|
||||
{ code: "HT", name: "Haiti" },
|
||||
{ code: "HM", name: "Heard Island & Mcdonald Islands" },
|
||||
{ code: "VA", name: "Holy See (Vatican City State)" },
|
||||
{ code: "HN", name: "Honduras" },
|
||||
{ code: "HK", name: "Hong Kong" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "ID", name: "Indonesia" },
|
||||
{ code: "IR", name: "Iran, Islamic Republic Of" },
|
||||
{ code: "IQ", name: "Iraq" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "IM", name: "Isle Of Man" },
|
||||
{ code: "IL", name: "Israel" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "JM", name: "Jamaica" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "JE", name: "Jersey" },
|
||||
{ code: "JO", name: "Jordan" },
|
||||
{ code: "KZ", name: "Kazakhstan" },
|
||||
{ code: "KE", name: "Kenya" },
|
||||
{ code: "KI", name: "Kiribati" },
|
||||
{ code: "KR", name: "Korea" },
|
||||
{ code: "KP", name: "North Korea" },
|
||||
{ code: "KW", name: "Kuwait" },
|
||||
{ code: "KG", name: "Kyrgyzstan" },
|
||||
{ code: "LA", name: "Lao People's Democratic Republic" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "LB", name: "Lebanon" },
|
||||
{ code: "LS", name: "Lesotho" },
|
||||
{ code: "LR", name: "Liberia" },
|
||||
{ code: "LY", name: "Libyan Arab Jamahiriya" },
|
||||
{ code: "LI", name: "Liechtenstein" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "MO", name: "Macao" },
|
||||
{ code: "MK", name: "Macedonia" },
|
||||
{ code: "MG", name: "Madagascar" },
|
||||
{ code: "MW", name: "Malawi" },
|
||||
{ code: "MY", name: "Malaysia" },
|
||||
{ code: "MV", name: "Maldives" },
|
||||
{ code: "ML", name: "Mali" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "MH", name: "Marshall Islands" },
|
||||
{ code: "MQ", name: "Martinique" },
|
||||
{ code: "MR", name: "Mauritania" },
|
||||
{ code: "MU", name: "Mauritius" },
|
||||
{ code: "YT", name: "Mayotte" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "FM", name: "Micronesia, Federated States Of" },
|
||||
{ code: "MD", name: "Moldova" },
|
||||
{ code: "MC", name: "Monaco" },
|
||||
{ code: "MN", name: "Mongolia" },
|
||||
{ code: "ME", name: "Montenegro" },
|
||||
{ code: "MS", name: "Montserrat" },
|
||||
{ code: "MA", name: "Morocco" },
|
||||
{ code: "MZ", name: "Mozambique" },
|
||||
{ code: "MM", name: "Myanmar" },
|
||||
{ code: "NA", name: "Namibia" },
|
||||
{ code: "NR", name: "Nauru" },
|
||||
{ code: "NP", name: "Nepal" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "AN", name: "Netherlands Antilles" },
|
||||
{ code: "NC", name: "New Caledonia" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "NI", name: "Nicaragua" },
|
||||
{ code: "NE", name: "Niger" },
|
||||
{ code: "NG", name: "Nigeria" },
|
||||
{ code: "NU", name: "Niue" },
|
||||
{ code: "NF", name: "Norfolk Island" },
|
||||
{ code: "MP", name: "Northern Mariana Islands" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "OM", name: "Oman" },
|
||||
{ code: "PK", name: "Pakistan" },
|
||||
{ code: "PW", name: "Palau" },
|
||||
{ code: "PS", name: "Palestinian Territory, Occupied" },
|
||||
{ code: "PA", name: "Panama" },
|
||||
{ code: "PG", name: "Papua New Guinea" },
|
||||
{ code: "PY", name: "Paraguay" },
|
||||
{ code: "PE", name: "Peru" },
|
||||
{ code: "PH", name: "Philippines" },
|
||||
{ code: "PN", name: "Pitcairn" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "PR", name: "Puerto Rico" },
|
||||
{ code: "QA", name: "Qatar" },
|
||||
{ code: "RE", name: "Reunion" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "RU", name: "Russian Federation" },
|
||||
{ code: "RW", name: "Rwanda" },
|
||||
{ code: "BL", name: "Saint Barthelemy" },
|
||||
{ code: "SH", name: "Saint Helena" },
|
||||
{ code: "KN", name: "Saint Kitts And Nevis" },
|
||||
{ code: "LC", name: "Saint Lucia" },
|
||||
{ code: "MF", name: "Saint Martin" },
|
||||
{ code: "PM", name: "Saint Pierre And Miquelon" },
|
||||
{ code: "VC", name: "Saint Vincent And Grenadines" },
|
||||
{ code: "WS", name: "Samoa" },
|
||||
{ code: "SM", name: "San Marino" },
|
||||
{ code: "ST", name: "Sao Tome And Principe" },
|
||||
{ code: "SA", name: "Saudi Arabia" },
|
||||
{ code: "SN", name: "Senegal" },
|
||||
{ code: "RS", name: "Serbia" },
|
||||
{ code: "SC", name: "Seychelles" },
|
||||
{ code: "SL", name: "Sierra Leone" },
|
||||
{ code: "SG", name: "Singapore" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SB", name: "Solomon Islands" },
|
||||
{ code: "SO", name: "Somalia" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "GS", name: "South Georgia And Sandwich Isl." },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "LK", name: "Sri Lanka" },
|
||||
{ code: "SD", name: "Sudan" },
|
||||
{ code: "SR", name: "Suriname" },
|
||||
{ code: "SJ", name: "Svalbard And Jan Mayen" },
|
||||
{ code: "SZ", name: "Swaziland" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "SY", name: "Syrian Arab Republic" },
|
||||
{ code: "TW", name: "Taiwan" },
|
||||
{ code: "TJ", name: "Tajikistan" },
|
||||
{ code: "TZ", name: "Tanzania" },
|
||||
{ code: "TH", name: "Thailand" },
|
||||
{ code: "TL", name: "Timor-Leste" },
|
||||
{ code: "TG", name: "Togo" },
|
||||
{ code: "TK", name: "Tokelau" },
|
||||
{ code: "TO", name: "Tonga" },
|
||||
{ code: "TT", name: "Trinidad And Tobago" },
|
||||
{ code: "TN", name: "Tunisia" },
|
||||
{ code: "TR", name: "Turkey" },
|
||||
{ code: "TM", name: "Turkmenistan" },
|
||||
{ code: "TC", name: "Turks And Caicos Islands" },
|
||||
{ code: "TV", name: "Tuvalu" },
|
||||
{ code: "UG", name: "Uganda" },
|
||||
{ code: "UA", name: "Ukraine" },
|
||||
{ code: "AE", name: "United Arab Emirates" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "US", name: "United States" },
|
||||
{ code: "UM", name: "United States Outlying Islands" },
|
||||
{ code: "UY", name: "Uruguay" },
|
||||
{ code: "UZ", name: "Uzbekistan" },
|
||||
{ code: "VU", name: "Vanuatu" },
|
||||
{ code: "VE", name: "Venezuela" },
|
||||
{ code: "VN", name: "Vietnam" },
|
||||
{ code: "VG", name: "Virgin Islands, British" },
|
||||
{ code: "VI", name: "Virgin Islands, U.S." },
|
||||
{ code: "WF", name: "Wallis And Futuna" },
|
||||
{ code: "EH", name: "Western Sahara" },
|
||||
{ code: "YE", name: "Yemen" },
|
||||
{ code: "ZM", name: "Zambia" },
|
||||
{ code: "ZW", name: "Zimbabwe" },
|
||||
];
|
||||
|
||||
// countries-zh.js
|
||||
export const ZH_COUNTRIES = [
|
||||
{ code: "AF", name: "阿富汗" },
|
||||
{ code: "AX", name: "奥兰群岛" },
|
||||
{ code: "AL", name: "阿尔巴尼亚" },
|
||||
{ code: "DZ", name: "阿尔及利亚" },
|
||||
{ code: "AS", name: "美属萨摩亚" },
|
||||
{ code: "AD", name: "安道尔" },
|
||||
{ code: "AO", name: "安哥拉" },
|
||||
{ code: "AI", name: "安圭拉" },
|
||||
{ code: "AQ", name: "南极洲" },
|
||||
{ code: "AG", name: "安提瓜和巴布达" },
|
||||
{ code: "AR", name: "阿根廷" },
|
||||
{ code: "AM", name: "亚美尼亚" },
|
||||
{ code: "AW", name: "阿鲁巴" },
|
||||
{ code: "AU", name: "澳大利亚" },
|
||||
{ code: "AT", name: "奥地利" },
|
||||
{ code: "AZ", name: "阿塞拜疆" },
|
||||
{ code: "BS", name: "巴哈马" },
|
||||
{ code: "BH", name: "巴林" },
|
||||
{ code: "BD", name: "孟加拉国" },
|
||||
{ code: "BB", name: "巴巴多斯" },
|
||||
{ code: "BY", name: "白俄罗斯" },
|
||||
{ code: "BE", name: "比利时" },
|
||||
{ code: "BZ", name: "伯利兹" },
|
||||
{ code: "BJ", name: "贝宁" },
|
||||
{ code: "BM", name: "百慕大" },
|
||||
{ code: "BT", name: "不丹" },
|
||||
{ code: "BO", name: "玻利维亚" },
|
||||
{ code: "BA", name: "波斯尼亚和黑塞哥维那" },
|
||||
{ code: "BW", name: "博茨瓦纳" },
|
||||
{ code: "BV", name: "布韦岛" },
|
||||
{ code: "BR", name: "巴西" },
|
||||
{ code: "IO", name: "英属印度洋领地" },
|
||||
{ code: "BN", name: "文莱" },
|
||||
{ code: "BG", name: "保加利亚" },
|
||||
{ code: "BF", name: "布基纳法索" },
|
||||
{ code: "BI", name: "布隆迪" },
|
||||
{ code: "KH", name: "柬埔寨" },
|
||||
{ code: "CM", name: "喀麦隆" },
|
||||
{ code: "CA", name: "加拿大" },
|
||||
{ code: "CV", name: "佛得角" },
|
||||
{ code: "KY", name: "开曼群岛" },
|
||||
{ code: "CF", name: "中非共和国" },
|
||||
{ code: "TD", name: "乍得" },
|
||||
{ code: "CL", name: "智利" },
|
||||
{ code: "CN", name: "中国" },
|
||||
{ code: "CX", name: "圣诞岛" },
|
||||
{ code: "CC", name: "科科斯(基林)群岛" },
|
||||
{ code: "CO", name: "哥伦比亚" },
|
||||
{ code: "KM", name: "科摩罗" },
|
||||
{ code: "CG", name: "刚果" },
|
||||
{ code: "CD", name: "刚果(金)" },
|
||||
{ code: "CK", name: "库克群岛" },
|
||||
{ code: "CR", name: "哥斯达黎加" },
|
||||
{ code: "CI", name: "科特迪瓦" },
|
||||
{ code: "HR", name: "克罗地亚" },
|
||||
{ code: "CU", name: "古巴" },
|
||||
{ code: "CY", name: "塞浦路斯" },
|
||||
{ code: "CZ", name: "捷克" },
|
||||
{ code: "DK", name: "丹麦" },
|
||||
{ code: "DJ", name: "吉布提" },
|
||||
{ code: "DM", name: "多米尼克" },
|
||||
{ code: "DO", name: "多米尼加共和国" },
|
||||
{ code: "EC", name: "厄瓜多尔" },
|
||||
{ code: "EG", name: "埃及" },
|
||||
{ code: "SV", name: "萨尔瓦多" },
|
||||
{ code: "GQ", name: "赤道几内亚" },
|
||||
{ code: "ER", name: "厄立特里亚" },
|
||||
{ code: "EE", name: "爱沙尼亚" },
|
||||
{ code: "ET", name: "埃塞俄比亚" },
|
||||
{ code: "FK", name: "福克兰群岛" },
|
||||
{ code: "FO", name: "法罗群岛" },
|
||||
{ code: "FJ", name: "斐济" },
|
||||
{ code: "FI", name: "芬兰" },
|
||||
{ code: "FR", name: "法国" },
|
||||
{ code: "GF", name: "法属圭亚那" },
|
||||
{ code: "PF", name: "法属波利尼西亚" },
|
||||
{ code: "TF", name: "法属南部领地" },
|
||||
{ code: "GA", name: "加蓬" },
|
||||
{ code: "GM", name: "冈比亚" },
|
||||
{ code: "GE", name: "格鲁吉亚" },
|
||||
{ code: "DE", name: "德国" },
|
||||
{ code: "GH", name: "加纳" },
|
||||
{ code: "GI", name: "直布罗陀" },
|
||||
{ code: "GR", name: "希腊" },
|
||||
{ code: "GL", name: "格陵兰" },
|
||||
{ code: "GD", name: "格林纳达" },
|
||||
{ code: "GP", name: "瓜德罗普" },
|
||||
{ code: "GU", name: "关岛" },
|
||||
{ code: "GT", name: "危地马拉" },
|
||||
{ code: "GG", name: "根西岛" },
|
||||
{ code: "GN", name: "几内亚" },
|
||||
{ code: "GW", name: "几内亚比绍" },
|
||||
{ code: "GY", name: "圭亚那" },
|
||||
{ code: "HT", name: "海地" },
|
||||
{ code: "HM", name: "赫德岛和麦克唐纳群岛" },
|
||||
{ code: "VA", name: "梵蒂冈" },
|
||||
{ code: "HN", name: "洪都拉斯" },
|
||||
{ code: "HK", name: "中国香港" },
|
||||
{ code: "HU", name: "匈牙利" },
|
||||
{ code: "IS", name: "冰岛" },
|
||||
{ code: "IN", name: "印度" },
|
||||
{ code: "ID", name: "印度尼西亚" },
|
||||
{ code: "IR", name: "伊朗" },
|
||||
{ code: "IQ", name: "伊拉克" },
|
||||
{ code: "IE", name: "爱尔兰" },
|
||||
{ code: "IM", name: "马恩岛" },
|
||||
{ code: "IL", name: "以色列" },
|
||||
{ code: "IT", name: "意大利" },
|
||||
{ code: "JM", name: "牙买加" },
|
||||
{ code: "JP", name: "日本" },
|
||||
{ code: "JE", name: "泽西岛" },
|
||||
{ code: "JO", name: "约旦" },
|
||||
{ code: "KZ", name: "哈萨克斯坦" },
|
||||
{ code: "KE", name: "肯尼亚" },
|
||||
{ code: "KI", name: "基里巴斯" },
|
||||
{ code: "KR", name: "韩国" },
|
||||
{ code: "KP", name: "朝鲜" },
|
||||
{ code: "KW", name: "科威特" },
|
||||
{ code: "KG", name: "吉尔吉斯斯坦" },
|
||||
{ code: "LA", name: "老挝" },
|
||||
{ code: "LV", name: "拉脱维亚" },
|
||||
{ code: "LB", name: "黎巴嫩" },
|
||||
{ code: "LS", name: "莱索托" },
|
||||
{ code: "LR", name: "利比里亚" },
|
||||
{ code: "LY", name: "利比亚" },
|
||||
{ code: "LI", name: "列支敦士登" },
|
||||
{ code: "LT", name: "立陶宛" },
|
||||
{ code: "LU", name: "卢森堡" },
|
||||
{ code: "MO", name: "中国澳门" },
|
||||
{ code: "MK", name: "北马其顿" },
|
||||
{ code: "MG", name: "马达加斯加" },
|
||||
{ code: "MW", name: "马拉维" },
|
||||
{ code: "MY", name: "马来西亚" },
|
||||
{ code: "MV", name: "马尔代夫" },
|
||||
{ code: "ML", name: "马里" },
|
||||
{ code: "MT", name: "马耳他" },
|
||||
{ code: "MH", name: "马绍尔群岛" },
|
||||
{ code: "MQ", name: "马提尼克" },
|
||||
{ code: "MR", name: "毛里塔尼亚" },
|
||||
{ code: "MU", name: "毛里求斯" },
|
||||
{ code: "YT", name: "马约特" },
|
||||
{ code: "MX", name: "墨西哥" },
|
||||
{ code: "FM", name: "密克罗尼西亚" },
|
||||
{ code: "MD", name: "摩尔多瓦" },
|
||||
{ code: "MC", name: "摩纳哥" },
|
||||
{ code: "MN", name: "蒙古" },
|
||||
{ code: "ME", name: "黑山" },
|
||||
{ code: "MS", name: "蒙特塞拉特" },
|
||||
{ code: "MA", name: "摩洛哥" },
|
||||
{ code: "MZ", name: "莫桑比克" },
|
||||
{ code: "MM", name: "缅甸" },
|
||||
{ code: "NA", name: "纳米比亚" },
|
||||
{ code: "NR", name: "瑙鲁" },
|
||||
{ code: "NP", name: "尼泊尔" },
|
||||
{ code: "NL", name: "荷兰" },
|
||||
{ code: "AN", name: "荷属安的列斯" },
|
||||
{ code: "NC", name: "新喀里多尼亚" },
|
||||
{ code: "NZ", name: "新西兰" },
|
||||
{ code: "NI", name: "尼加拉瓜" },
|
||||
{ code: "NE", name: "尼日尔" },
|
||||
{ code: "NG", name: "尼日利亚" },
|
||||
{ code: "NU", name: "纽埃" },
|
||||
{ code: "NF", name: "诺福克岛" },
|
||||
{ code: "MP", name: "北马里亚纳群岛" },
|
||||
{ code: "NO", name: "挪威" },
|
||||
{ code: "OM", name: "阿曼" },
|
||||
{ code: "PK", name: "巴基斯坦" },
|
||||
{ code: "PW", name: "帕劳" },
|
||||
{ code: "PS", name: "巴勒斯坦" },
|
||||
{ code: "PA", name: "巴拿马" },
|
||||
{ code: "PG", name: "巴布亚新几内亚" },
|
||||
{ code: "PY", name: "巴拉圭" },
|
||||
{ code: "PE", name: "秘鲁" },
|
||||
{ code: "PH", name: "菲律宾" },
|
||||
{ code: "PN", name: "皮特凯恩群岛" },
|
||||
{ code: "PL", name: "波兰" },
|
||||
{ code: "PT", name: "葡萄牙" },
|
||||
{ code: "PR", name: "波多黎各" },
|
||||
{ code: "QA", name: "卡塔尔" },
|
||||
{ code: "RE", name: "留尼汪" },
|
||||
{ code: "RO", name: "罗马尼亚" },
|
||||
{ code: "RU", name: "俄罗斯" },
|
||||
{ code: "RW", name: "卢旺达" },
|
||||
{ code: "BL", name: "圣巴泰勒米" },
|
||||
{ code: "SH", name: "圣赫勒拿" },
|
||||
{ code: "KN", name: "圣基茨和尼维斯" },
|
||||
{ code: "LC", name: "圣卢西亚" },
|
||||
{ code: "MF", name: "法属圣马丁" },
|
||||
{ code: "PM", name: "圣皮埃尔和密克隆" },
|
||||
{ code: "VC", name: "圣文森特和格林纳丁斯" },
|
||||
{ code: "WS", name: "萨摩亚" },
|
||||
{ code: "SM", name: "圣马力诺" },
|
||||
{ code: "ST", name: "圣多美和普林西比" },
|
||||
{ code: "SA", name: "沙特阿拉伯" },
|
||||
{ code: "SN", name: "塞内加尔" },
|
||||
{ code: "RS", name: "塞尔维亚" },
|
||||
{ code: "SC", name: "塞舌尔" },
|
||||
{ code: "SL", name: "塞拉利昂" },
|
||||
{ code: "SG", name: "新加坡" },
|
||||
{ code: "SK", name: "斯洛伐克" },
|
||||
{ code: "SI", name: "斯洛文尼亚" },
|
||||
{ code: "SB", name: "所罗门群岛" },
|
||||
{ code: "SO", name: "索马里" },
|
||||
{ code: "ZA", name: "南非" },
|
||||
{ code: "GS", name: "南乔治亚和南桑威奇群岛" },
|
||||
{ code: "ES", name: "西班牙" },
|
||||
{ code: "LK", name: "斯里兰卡" },
|
||||
{ code: "SD", name: "苏丹" },
|
||||
{ code: "SR", name: "苏里南" },
|
||||
{ code: "SJ", name: "斯瓦尔巴和扬马延" },
|
||||
{ code: "SZ", name: "斯威士兰" },
|
||||
{ code: "SE", name: "瑞典" },
|
||||
{ code: "CH", name: "瑞士" },
|
||||
{ code: "SY", name: "叙利亚" },
|
||||
{ code: "TW", name: "中国台湾" },
|
||||
{ code: "TJ", name: "塔吉克斯坦" },
|
||||
{ code: "TZ", name: "坦桑尼亚" },
|
||||
{ code: "TH", name: "泰国" },
|
||||
{ code: "TL", name: "东帝汶" },
|
||||
{ code: "TG", name: "多哥" },
|
||||
{ code: "TK", name: "托克劳" },
|
||||
{ code: "TO", name: "汤加" },
|
||||
{ code: "TT", name: "特立尼达和多巴哥" },
|
||||
{ code: "TN", name: "突尼斯" },
|
||||
{ code: "TR", name: "土耳其" },
|
||||
{ code: "TM", name: "土库曼斯坦" },
|
||||
{ code: "TC", name: "特克斯和凯科斯群岛" },
|
||||
{ code: "TV", name: "图瓦卢" },
|
||||
{ code: "UG", name: "乌干达" },
|
||||
{ code: "UA", name: "乌克兰" },
|
||||
{ code: "AE", name: "阿联酋" },
|
||||
{ code: "GB", name: "英国" },
|
||||
{ code: "US", name: "美国" },
|
||||
{ code: "UM", name: "美国本土外小岛屿" },
|
||||
{ code: "UY", name: "乌拉圭" },
|
||||
{ code: "UZ", name: "乌兹别克斯坦" },
|
||||
{ code: "VU", name: "瓦努阿图" },
|
||||
{ code: "VE", name: "委内瑞拉" },
|
||||
{ code: "VN", name: "越南" },
|
||||
{ code: "VG", name: "英属维尔京群岛" },
|
||||
{ code: "VI", name: "美属维尔京群岛" },
|
||||
{ code: "WF", name: "瓦利斯和富图纳" },
|
||||
{ code: "EH", name: "西撒哈拉" },
|
||||
{ code: "YE", name: "也门" },
|
||||
{ code: "ZM", name: "赞比亚" },
|
||||
{ code: "ZW", name: "津巴布韦" },
|
||||
];
|
||||
|
||||
85
src/shared/const/mediaTypes.ts
Normal file
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Допустимые типы и расширения файлов для медиа
|
||||
export const ALLOWED_MEDIA_TYPES = {
|
||||
image: {
|
||||
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||
mimeTypes: [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
],
|
||||
accept: "image/*",
|
||||
},
|
||||
video: {
|
||||
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||
mimeTypes: [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
],
|
||||
accept: "video/*",
|
||||
},
|
||||
model3d: {
|
||||
extensions: [".glb", ".gltf"],
|
||||
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||
accept: ".glb,.gltf",
|
||||
},
|
||||
panorama: {
|
||||
extensions: [".jpg", ".jpeg", ".png"],
|
||||
mimeTypes: ["image/jpeg", "image/png"],
|
||||
accept: "image/*",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getAllAllowedExtensions = (): string[] => {
|
||||
return [
|
||||
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||
];
|
||||
};
|
||||
|
||||
export const getAllAcceptString = (): string => {
|
||||
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||
};
|
||||
|
||||
export const validateFileExtension = (
|
||||
file: File
|
||||
): { valid: boolean; error?: string } => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||
const allowedExtensions = getAllAllowedExtensions();
|
||||
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||
", "
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const filterValidFiles = (
|
||||
files: File[]
|
||||
): { validFiles: File[]; errors: string[] } => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const validation = validateFileExtension(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(`${file.name}: ${validation.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { validFiles, errors };
|
||||
};
|
||||
1
src/shared/hooks/index.ts
Normal file
1
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useSelectedCity";
|
||||
12
src/shared/hooks/useSelectedCity.ts
Normal file
12
src/shared/hooks/useSelectedCity.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { selectedCityStore } from "@shared";
|
||||
|
||||
export const useSelectedCity = () => {
|
||||
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
|
||||
|
||||
return {
|
||||
selectedCity,
|
||||
selectedCityId,
|
||||
selectedCityName,
|
||||
hasSelectedCity: !!selectedCity,
|
||||
};
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from "./store";
|
||||
export * from "./const";
|
||||
export * from "./api";
|
||||
export * from "./modals";
|
||||
export * from "./hooks";
|
||||
|
||||
82
src/shared/lib/gltfCacheManager.ts
Normal file
82
src/shared/lib/gltfCacheManager.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Утилита для управления кешем GLTF и blob URL
|
||||
*/
|
||||
|
||||
// Динамический импорт useGLTF для избежания проблем с SSR
|
||||
let useGLTF: any = null;
|
||||
|
||||
const initializeUseGLTF = async () => {
|
||||
if (!useGLTF) {
|
||||
try {
|
||||
const drei = await import("@react-three/drei");
|
||||
useGLTF = drei.useGLTF;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return useGLTF;
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает кеш GLTF для конкретного URL
|
||||
*/
|
||||
export const clearGLTFCacheForUrl = async (url: string) => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
if (gltf && gltf.clear) {
|
||||
gltf.clear(url);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает весь кеш GLTF
|
||||
*/
|
||||
export const clearAllGLTFCache = async () => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
if (gltf && gltf.clear) {
|
||||
gltf.clear();
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает blob URL из памяти браузера
|
||||
*/
|
||||
export const revokeBlobURL = (url: string) => {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Комплексная очистка: blob URL + кеш GLTF
|
||||
*/
|
||||
export const clearBlobAndGLTFCache = async (url: string) => {
|
||||
// Сначала отзываем blob URL
|
||||
revokeBlobURL(url);
|
||||
|
||||
// Затем очищаем кеш GLTF
|
||||
await clearGLTFCacheForUrl(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Очистка при смене медиа (для предотвращения конфликтов)
|
||||
*/
|
||||
export const clearMediaTransitionCache = async (
|
||||
previousMediaId: string | number | null,
|
||||
newMediaId: string | number | null,
|
||||
newMediaType?: number
|
||||
) => {
|
||||
console.log(newMediaId, newMediaType);
|
||||
// Если переключаемся с/на 3D модель, очищаем весь кеш
|
||||
if (newMediaType === 6 || previousMediaId) {
|
||||
await clearAllGLTFCache();
|
||||
}
|
||||
};
|
||||
@@ -1,2 +1,55 @@
|
||||
export * from "./mui/theme";
|
||||
export * from "./DecodeJWT";
|
||||
export * from "./gltfCacheManager";
|
||||
|
||||
/**
|
||||
* Генерирует название медиа по умолчанию в разных форматах
|
||||
*
|
||||
* Примеры использования:
|
||||
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
|
||||
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
|
||||
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
|
||||
*
|
||||
* @param objectName - Название объекта (достопримечательности, города и т.д.)
|
||||
* @param fileName - Название файла
|
||||
* @param mediaType - Тип медиа (число) или название статьи
|
||||
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
|
||||
* @returns Строка в нужном формате
|
||||
*/
|
||||
export const generateDefaultMediaName = (
|
||||
objectName: string,
|
||||
fileName: string,
|
||||
mediaType: number | string,
|
||||
isArticle: boolean = false
|
||||
): string => {
|
||||
// Убираем расширение из названия файла
|
||||
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
||||
|
||||
if (isArticle && typeof mediaType === "string") {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
||||
} else if (typeof mediaType === "number") {
|
||||
// Получаем название типа медиа
|
||||
const mediaTypeLabels: Record<number, string> = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
3: "Иконка",
|
||||
4: "Водяной знак",
|
||||
5: "Панорама",
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
||||
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
||||
|
||||
if (objectName && objectName.trim() !== "") {
|
||||
// Если есть название объекта: "Название объекта_название файла_тип медиа"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
} else {
|
||||
// Если нет названия объекта: "Название_название файла_тип медиа"
|
||||
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
|
||||
className="flex gap-4"
|
||||
dividers
|
||||
sx={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
@@ -132,7 +132,7 @@ export const PreviewMediaDialog = observer(
|
||||
sx={{ width: "50%" }}
|
||||
/>
|
||||
|
||||
<Box className="flex gap-4">
|
||||
<Box className="flex gap-4 h-[40vh]">
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
@@ -142,7 +142,6 @@ export const PreviewMediaDialog = observer(
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
className="max-h-[40vh]"
|
||||
>
|
||||
<MediaViewer
|
||||
media={{
|
||||
@@ -150,6 +149,7 @@ export const PreviewMediaDialog = observer(
|
||||
media_type: media.media_type,
|
||||
filename: media.filename,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
fullHeight
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
@@ -38,7 +38,9 @@ export const SelectArticleModal = observer(
|
||||
onSelectArticle,
|
||||
linkedArticleIds = [],
|
||||
}: SelectArticleModalProps) => {
|
||||
const { articles, getArticle, getArticleMedia } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const { articles, getArticle, getArticleMedia, getArticles } =
|
||||
articlesStore;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
|
||||
null
|
||||
@@ -54,6 +56,21 @@ export const SelectArticleModal = observer(
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await getArticles("ru");
|
||||
await getArticles("en");
|
||||
await getArticles("zh");
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArticleId) {
|
||||
handleArticleClick(selectedArticleId);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = async (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "enter") {
|
||||
@@ -273,6 +290,25 @@ export const SelectArticleModal = observer(
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
}}
|
||||
onDoubleClick={async () => {
|
||||
if (selectedArticleId) {
|
||||
const media = await authInstance.get(
|
||||
`/article/${selectedArticleId}/media`
|
||||
);
|
||||
onSelectArticle(
|
||||
selectedArticleId,
|
||||
articlesStore.articleData?.heading || "",
|
||||
articlesStore.articleData?.body || "",
|
||||
media.data || []
|
||||
);
|
||||
onClose();
|
||||
setSelectedArticleId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{articlesStore.articleData?.heading || "Название cтатьи"}
|
||||
|
||||
@@ -102,7 +102,6 @@ export const SelectMediaDialog = observer(
|
||||
filteredMedia = filteredMedia.filter(
|
||||
(mediaItem) => mediaItem.media_type === mediaType
|
||||
);
|
||||
console.log(filteredMedia);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,7 +162,13 @@ export const SelectMediaDialog = observer(
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={mediaItem.media_name} />
|
||||
<ListItemText
|
||||
primary={
|
||||
mediaItem.media_name
|
||||
? mediaItem.media_name
|
||||
: mediaItem.filename
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { MEDIA_TYPE_LABELS, MEDIA_TYPE_VALUES, editSightStore } from "@shared";
|
||||
import {
|
||||
MEDIA_TYPE_LABELS,
|
||||
MEDIA_TYPE_VALUES,
|
||||
editSightStore,
|
||||
generateDefaultMediaName,
|
||||
clearBlobAndGLTFCache,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -31,7 +37,24 @@ interface UploadMediaDialogProps {
|
||||
media_type: number;
|
||||
}) => void;
|
||||
afterUploadSight?: (id: string) => void;
|
||||
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | null;
|
||||
hardcodeType?:
|
||||
| "thumbnail"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
| "video_preview"
|
||||
| null;
|
||||
contextObjectName?: string;
|
||||
contextType?:
|
||||
| "sight"
|
||||
| "city"
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File; // <--- добавлено
|
||||
}
|
||||
|
||||
export const UploadMediaDialog = observer(
|
||||
@@ -41,6 +64,11 @@ export const UploadMediaDialog = observer(
|
||||
afterUpload,
|
||||
afterUploadSight,
|
||||
hardcodeType,
|
||||
contextObjectName,
|
||||
|
||||
isArticle,
|
||||
articleName,
|
||||
initialFile, // <--- добавлено
|
||||
}: UploadMediaDialogProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,6 +82,41 @@ export const UploadMediaDialog = observer(
|
||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
|
||||
[]
|
||||
);
|
||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||
const previousMediaUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFile) {
|
||||
// Очищаем предыдущий blob URL если он существует
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
setMediaFile(initialFile);
|
||||
setMediaFilename(initialFile.name);
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
|
||||
}
|
||||
}, [initialFile]);
|
||||
|
||||
// Очистка blob URL при размонтировании компонента
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
|
||||
|
||||
useEffect(() => {
|
||||
if (fileToUpload) {
|
||||
@@ -66,7 +129,11 @@ export const UploadMediaDialog = observer(
|
||||
setAvailableMediaTypes([6]);
|
||||
setMediaType(6);
|
||||
}
|
||||
if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
// Для изображений доступны все типы кроме видео
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
|
||||
setMediaType(1); // По умолчанию Фото
|
||||
@@ -76,14 +143,112 @@ export const UploadMediaDialog = observer(
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем название по умолчанию если есть контекст
|
||||
if (fileToUpload.name) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
articleName,
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
defaultName = generateDefaultMediaName(
|
||||
"",
|
||||
fileToUpload.name,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
setMediaName(defaultName);
|
||||
}
|
||||
}
|
||||
}, [fileToUpload]);
|
||||
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
||||
|
||||
// Обновляем название при изменении типа медиа
|
||||
useEffect(() => {
|
||||
if (mediaFilename && mediaType > 0) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
articleName,
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
defaultName = generateDefaultMediaName(
|
||||
"",
|
||||
mediaFilename,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
setMediaName(defaultName);
|
||||
}
|
||||
}, [
|
||||
mediaType,
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
hardcodeType,
|
||||
isArticle,
|
||||
articleName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaFile) {
|
||||
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
|
||||
// Очищаем предыдущий blob URL и кеш GLTF если он существует
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
|
||||
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
|
||||
}
|
||||
}, [mediaFile]);
|
||||
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
|
||||
|
||||
// const fileFormat = useEffect(() => {
|
||||
// const handleKeyPress = (event: KeyboardEvent) => {
|
||||
@@ -120,6 +285,10 @@ export const UploadMediaDialog = observer(
|
||||
}
|
||||
}
|
||||
setSuccess(true);
|
||||
// Закрываем модальное окно после успешного сохранения
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
@@ -128,8 +297,20 @@ export const UploadMediaDialog = observer(
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Очищаем blob URL и кеш GLTF при закрытии диалога
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setMediaUrl(null);
|
||||
setMediaFile(null);
|
||||
setIsPreviewLoaded(false);
|
||||
previousMediaUrlRef.current = null; // Очищаем ref
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -141,7 +322,6 @@ export const UploadMediaDialog = observer(
|
||||
className="flex gap-4"
|
||||
dividers
|
||||
sx={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
@@ -198,27 +378,51 @@ export const UploadMediaDialog = observer(
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* <MediaViewer
|
||||
media={{
|
||||
id: "",
|
||||
media_type: mediaType,
|
||||
filename: mediaFilename,
|
||||
}}
|
||||
/> */}
|
||||
{!isPreviewLoaded && mediaUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{mediaType == 2 && mediaUrl && (
|
||||
<video
|
||||
src={mediaUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
controls
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
onLoadedData={() => setIsPreviewLoaded(true)}
|
||||
onError={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{mediaType === 6 && mediaUrl && (
|
||||
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
|
||||
<ModelViewer3D
|
||||
fileUrl={mediaUrl}
|
||||
height="100%"
|
||||
onLoad={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
|
||||
<img
|
||||
src={mediaUrl ?? ""}
|
||||
alt="Uploaded media"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onLoad={() => setIsPreviewLoaded(true)}
|
||||
onError={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -226,18 +430,31 @@ export const UploadMediaDialog = observer(
|
||||
<Box className="flex flex-col gap-2 self-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
sx={{
|
||||
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
|
||||
"&:hover": {
|
||||
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
|
||||
},
|
||||
}}
|
||||
startIcon={
|
||||
isLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (!mediaName && !mediaFilename)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(!mediaName && !mediaFilename) ||
|
||||
!isPreviewLoaded
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
{isLoading
|
||||
? "Сохранение..."
|
||||
: !isPreviewLoaded
|
||||
? "Загрузка превью..."
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -109,7 +109,8 @@ class ArticlesStore {
|
||||
|
||||
getArticles = async (language: Language) => {
|
||||
this.articleLoading = true;
|
||||
const response = await authInstance.get("/article");
|
||||
|
||||
const response = await languageInstance(language).get("/article");
|
||||
|
||||
runInAction(() => {
|
||||
this.articles[language] = response.data;
|
||||
@@ -119,8 +120,9 @@ class ArticlesStore {
|
||||
|
||||
getArticle = async (id: number, language?: Language) => {
|
||||
this.articleLoading = true;
|
||||
let response: any;
|
||||
if (language) {
|
||||
const response = await languageInstance(language).get(`/article/${id}`);
|
||||
response = await languageInstance(language).get(`/article/${id}`);
|
||||
runInAction(() => {
|
||||
if (!this.articleData) {
|
||||
this.articleData = { id, heading: "", body: "", service_name: "" };
|
||||
@@ -131,11 +133,12 @@ class ArticlesStore {
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const response = await authInstance.get(`/article/${id}`);
|
||||
response = await authInstance.get(`/article/${id}`);
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
}
|
||||
return response;
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,11 @@ class AuthStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.setAuthToken(data.token);
|
||||
this.payload = response.data;
|
||||
this.payload = {
|
||||
...response.data.user,
|
||||
// @ts-ignore
|
||||
user_id: response.data.user.id,
|
||||
};
|
||||
this.error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -64,7 +64,7 @@ class CarrierStore {
|
||||
getCarriers = async (language: Language) => {
|
||||
if (this.carriers[language as keyof Carriers].loaded) return;
|
||||
|
||||
const response = await authInstance.get("/carrier");
|
||||
const response = await languageInstance(language).get("/carrier");
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers[language as keyof Carriers].data = response.data;
|
||||
@@ -108,46 +108,114 @@ class CarrierStore {
|
||||
return this.carrier[id];
|
||||
};
|
||||
|
||||
createCarrier = async (
|
||||
createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
|
||||
setCreateCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
cityId: number,
|
||||
slogan: string,
|
||||
logoId: string
|
||||
logoId: string,
|
||||
language: Language
|
||||
) => {
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[language].data.find((city) => city.id === cityId)
|
||||
?.name || "";
|
||||
|
||||
const response = await languageInstance(language).post("/carrier", {
|
||||
this.createCarrierData.city_id = cityId;
|
||||
this.createCarrierData.logo = logoId;
|
||||
this.createCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
|
||||
createCarrier = async () => {
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.createCarrierData.city_id
|
||||
)?.name || "";
|
||||
|
||||
const payload = {
|
||||
full_name: this.createCarrierData[language].full_name,
|
||||
short_name: this.createCarrierData[language].short_name,
|
||||
city: cityName,
|
||||
city_id: cityId,
|
||||
slogan,
|
||||
logo: logoId,
|
||||
});
|
||||
city_id: this.createCarrierData.city_id,
|
||||
slogan: this.createCarrierData[language].slogan,
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(language).post("/carrier", payload);
|
||||
|
||||
const carrierId = response.data.id;
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers[language].data.push(response.data);
|
||||
});
|
||||
|
||||
// Create translations for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||
await languageInstance(lang as Language).patch(`/carrier/${carrierId}`, {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
const patchPayload = {
|
||||
// @ts-ignore
|
||||
full_name: this.createCarrierData[lang as any].full_name as string,
|
||||
// @ts-ignore
|
||||
short_name: this.createCarrierData[lang as any].short_name as string,
|
||||
city: cityName,
|
||||
city_id: cityId,
|
||||
slogan,
|
||||
logo: logoId,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
// @ts-ignore
|
||||
slogan: this.createCarrierData[lang as any].slogan as string,
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(lang as Language).patch(
|
||||
`/carrier/${carrierId}`,
|
||||
patchPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers[lang as keyof Carriers].data.push(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
for (const language of ["ru", "en", "zh"] as const) {
|
||||
this.carriers[language].data.push(response.data);
|
||||
}
|
||||
});
|
||||
this.createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
editCarrierData = {
|
||||
@@ -206,31 +274,31 @@ class CarrierStore {
|
||||
};
|
||||
|
||||
editCarrier = async (id: number) => {
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[languageStore.language].data.find(
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.editCarrierData.city_id
|
||||
)?.name || "";
|
||||
|
||||
for (const language of ["ru", "en", "zh"] as const) {
|
||||
const response = await languageInstance(language).patch(
|
||||
`/carrier/${id}`,
|
||||
{
|
||||
...this.editCarrierData[language],
|
||||
city: cityName,
|
||||
logo: this.editCarrierData.logo,
|
||||
}
|
||||
);
|
||||
for (const lang of ["ru", "en", "zh"] as const) {
|
||||
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
|
||||
...this.editCarrierData[lang],
|
||||
city: cityName,
|
||||
city_id: this.editCarrierData.city_id,
|
||||
...(this.editCarrierData.logo
|
||||
? { logo: this.editCarrierData.logo }
|
||||
: {}),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
if (this.carrier[id]) {
|
||||
this.carrier[id][language] = response.data;
|
||||
}
|
||||
for (const language of ["ru", "en", "zh"] as const) {
|
||||
this.carriers[language].data = this.carriers[language].data.map(
|
||||
(carrier: Carrier) =>
|
||||
carrier.id === id ? { ...carrier, ...response.data } : carrier
|
||||
);
|
||||
this.carrier[id][lang] = response.data;
|
||||
}
|
||||
|
||||
this.carriers[lang].data = this.carriers[lang].data.map(
|
||||
(carrier: Carrier) =>
|
||||
carrier.id === id ? { ...carrier, ...response.data } : carrier
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ class CityStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/city`);
|
||||
const response = await languageInstance(language).get(`/city`);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language].data = response.data;
|
||||
@@ -98,7 +98,7 @@ class CityStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/city/${code}`);
|
||||
const response = await languageInstance(language).get(`/city/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
if (!this.city[code]) {
|
||||
@@ -170,15 +170,20 @@ class CityStore {
|
||||
|
||||
try {
|
||||
// Create city in primary language
|
||||
const cityResponse = await languageInstance(language).post("/city", {
|
||||
const cityPayload = {
|
||||
name,
|
||||
country:
|
||||
countryStore.countries[language as keyof CashedCountries]?.data.find(
|
||||
(c) => c.code === country_code
|
||||
)?.name || "",
|
||||
country_code,
|
||||
arms: arms || "",
|
||||
});
|
||||
...(arms ? { arms } : {}),
|
||||
};
|
||||
|
||||
const cityResponse = await languageInstance(language).post(
|
||||
"/city",
|
||||
cityPayload
|
||||
);
|
||||
|
||||
const cityId = cityResponse.data.id;
|
||||
|
||||
@@ -194,14 +199,16 @@ class CityStore {
|
||||
(c) => c.code === country_code
|
||||
)?.name || "";
|
||||
|
||||
const patchPayload = {
|
||||
name: secondaryName || "",
|
||||
country: countryName,
|
||||
country_code: country_code || "",
|
||||
...(arms ? { arms } : {}),
|
||||
};
|
||||
|
||||
const patchResponse = await languageInstance(secondaryLanguage).patch(
|
||||
`/city/${cityId}`,
|
||||
{
|
||||
name: secondaryName || "",
|
||||
country: countryName,
|
||||
country_code: country_code || "",
|
||||
arms: arms || "",
|
||||
}
|
||||
patchPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
@@ -277,41 +284,39 @@ class CityStore {
|
||||
(country) => country.code === country_code
|
||||
);
|
||||
|
||||
if (name) {
|
||||
await languageInstance(language as Language).patch(`/city/${code}`, {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
});
|
||||
await languageInstance(language as Language).patch(`/city/${code}`, {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
if (this.city[code]) {
|
||||
this.city[code][language as keyof CashedCities] = {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
};
|
||||
}
|
||||
runInAction(() => {
|
||||
if (this.city[code]) {
|
||||
this.city[code][language as keyof CashedCities] = {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.cities[language as keyof CashedCities]) {
|
||||
this.cities[language as keyof CashedCities].data = this.cities[
|
||||
language as keyof CashedCities
|
||||
].data.map((city) =>
|
||||
city.id === Number(code)
|
||||
? {
|
||||
id: city.id,
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
}
|
||||
: city
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.cities[language as keyof CashedCities]) {
|
||||
this.cities[language as keyof CashedCities].data = this.cities[
|
||||
language as keyof CashedCities
|
||||
].data.map((city) =>
|
||||
city.id === Number(code)
|
||||
? {
|
||||
id: city.id,
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
}
|
||||
: city
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,14 +68,7 @@ class CountryStore {
|
||||
};
|
||||
|
||||
getCountry = async (code: string, language: keyof CashedCountries) => {
|
||||
if (
|
||||
this.country[code]?.[language] &&
|
||||
this.country[code][language] !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/country/${code}`);
|
||||
const response = await languageInstance(language).get(`/country/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
if (!this.country[code]) {
|
||||
@@ -91,14 +84,16 @@ class CountryStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
deleteCountry = async (code: string, language: keyof CashedCountries) => {
|
||||
deleteCountry = async (code: string) => {
|
||||
await authInstance.delete(`/country/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.countries[language].data = this.countries[language].data.filter(
|
||||
(country) => country.code !== code
|
||||
);
|
||||
this.countries[language].loaded = true;
|
||||
for (const lang of ["ru", "en", "zh"]) {
|
||||
this.countries[lang as keyof CashedCountries].data = this.countries[
|
||||
lang as keyof CashedCountries
|
||||
].data.filter((country) => country.code !== code);
|
||||
}
|
||||
|
||||
this.country[code] = {
|
||||
ru: null,
|
||||
en: null,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @shared/stores/createSightStore.ts
|
||||
import { Language, authInstance, languageInstance, mediaStore } from "@shared";
|
||||
import {
|
||||
articlesStore,
|
||||
Language,
|
||||
authInstance,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
type MediaItem = {
|
||||
@@ -162,6 +168,8 @@ class CreateSightStore {
|
||||
media: mediaData,
|
||||
});
|
||||
});
|
||||
|
||||
return articleId; // Return the linked article ID
|
||||
} catch (error) {
|
||||
console.error("Error linking existing right article:", error);
|
||||
throw error;
|
||||
@@ -315,7 +323,18 @@ class CreateSightStore {
|
||||
deleteLeftArticle = async (articleId: number) => {
|
||||
/* ... your existing logic ... */
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
// articlesStore.getArticles(languageStore.language); // If still needed
|
||||
// articlesStore.getArticles(languageStore.language); // If still neede
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||
(article) => article.id !== articleId
|
||||
);
|
||||
articlesStore.articles.en = articlesStore.articles.en.filter(
|
||||
(article) => article.id !== articleId
|
||||
);
|
||||
articlesStore.articles.zh = articlesStore.articles.zh.filter(
|
||||
(article) => article.id !== articleId
|
||||
);
|
||||
});
|
||||
this.unlinkLeftArticle();
|
||||
};
|
||||
|
||||
@@ -352,6 +371,25 @@ class CreateSightStore {
|
||||
body: "填写内容",
|
||||
media: [],
|
||||
};
|
||||
|
||||
articlesStore.articles.ru.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
service_name: "Новая левая статья",
|
||||
});
|
||||
articlesStore.articles.en.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
service_name: "New Left Article",
|
||||
});
|
||||
articlesStore.articles.zh.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
service_name: "新的左侧文章",
|
||||
});
|
||||
});
|
||||
return newLeftArticleId;
|
||||
};
|
||||
@@ -513,8 +551,8 @@ class CreateSightStore {
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Sight created with ID:", newSightId);
|
||||
// Optionally: this.clearCreateSight(); // To reset form after successful creation
|
||||
this.needLeaveAgree = false;
|
||||
return newSightId;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class DevicesStore {
|
||||
|
||||
getDevices = async () => {
|
||||
const response = await authInstance.get(`${API_URL}/devices/connected`);
|
||||
|
||||
runInAction(() => {
|
||||
this.devices = response.data;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @shared/stores/editSightStore.ts
|
||||
import { authInstance, Language, languageInstance, mediaStore } from "@shared";
|
||||
import {
|
||||
articlesStore,
|
||||
authInstance,
|
||||
Language,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type SightLanguageInfo = {
|
||||
@@ -82,11 +88,7 @@ class EditSightStore {
|
||||
|
||||
hasLoadedCommon = false;
|
||||
getSightInfo = async (id: number, language: Language) => {
|
||||
if (this.sight[language].id === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/sight/${id}`);
|
||||
const response = await languageInstance(language).get(`/sight/${id}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.left_article != 0 && data.left_article != null) {
|
||||
@@ -346,6 +348,8 @@ class EditSightStore {
|
||||
// body: this.sight.zh.left.body,
|
||||
// }
|
||||
// );
|
||||
|
||||
this.needLeaveAgree = false;
|
||||
};
|
||||
|
||||
getLeftArticle = async (id: number) => {
|
||||
@@ -374,6 +378,18 @@ class EditSightStore {
|
||||
|
||||
deleteLeftArticle = async (id: number) => {
|
||||
await authInstance.delete(`/article/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||
(article) => article.id !== id
|
||||
);
|
||||
articlesStore.articles.en = articlesStore.articles.en.filter(
|
||||
(article) => article.id !== id
|
||||
);
|
||||
articlesStore.articles.zh = articlesStore.articles.zh.filter(
|
||||
(article) => article.id !== id
|
||||
);
|
||||
});
|
||||
this.sight.common.left_article = 0;
|
||||
this.sight.ru.left.heading = "";
|
||||
this.sight.en.left.heading = "";
|
||||
@@ -481,9 +497,7 @@ class EditSightStore {
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
createLinkWithArticle = async (media: {
|
||||
@@ -554,6 +568,8 @@ class EditSightStore {
|
||||
media: mediaIds.data,
|
||||
});
|
||||
});
|
||||
|
||||
return article_id; // Return the linked article ID
|
||||
};
|
||||
|
||||
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
|
||||
@@ -637,6 +653,29 @@ class EditSightStore {
|
||||
body: articleZhData.body,
|
||||
media: [],
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru.push({
|
||||
id: id,
|
||||
heading: articleRuData.heading,
|
||||
body: articleRuData.body,
|
||||
service_name: articleRuData.heading,
|
||||
});
|
||||
articlesStore.articles.en.push({
|
||||
id: id,
|
||||
heading: articleEnData.heading,
|
||||
body: articleEnData.body,
|
||||
service_name: articleEnData.heading,
|
||||
});
|
||||
articlesStore.articles.zh.push({
|
||||
id: id,
|
||||
heading: articleZhData.heading,
|
||||
body: articleZhData.body,
|
||||
service_name: articleZhData.heading,
|
||||
});
|
||||
});
|
||||
|
||||
return id; // Return the ID of the newly created article
|
||||
};
|
||||
|
||||
createLinkWithRightArticle = async (
|
||||
|
||||
15
src/shared/store/MenuStore/index.ts
Normal file
15
src/shared/store/MenuStore/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
class MenuStore {
|
||||
isOpen: boolean = true;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
setIsMenuOpen = (isOpen: boolean) => {
|
||||
this.isOpen = isOpen;
|
||||
};
|
||||
}
|
||||
|
||||
export const menuStore = new MenuStore();
|
||||
101
src/shared/store/ModelLoadingStore/index.ts
Normal file
101
src/shared/store/ModelLoadingStore/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
export interface ModelLoadingState {
|
||||
isLoading: boolean;
|
||||
progress: number;
|
||||
modelId: string | null;
|
||||
error?: string;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
class ModelLoadingStore {
|
||||
private loadingStates: Map<string, ModelLoadingState> = new Map();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// Начать отслеживание загрузки модели
|
||||
startLoading(modelId: string) {
|
||||
this.loadingStates.set(modelId, {
|
||||
isLoading: true,
|
||||
progress: 0,
|
||||
modelId,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить прогресс загрузки
|
||||
updateProgress(modelId: string, progress: number) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
state.progress = Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
}
|
||||
|
||||
// Завершить загрузку модели
|
||||
finishLoading(modelId: string) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
state.isLoading = false;
|
||||
state.progress = 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Остановить загрузку (в случае ошибки)
|
||||
stopLoading(modelId: string) {
|
||||
this.loadingStates.delete(modelId);
|
||||
}
|
||||
|
||||
// Обработать ошибку загрузки
|
||||
handleError(modelId: string, error?: string) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
state.isLoading = false;
|
||||
state.error = error || "Ошибка загрузки модели";
|
||||
}
|
||||
}
|
||||
|
||||
// Получить состояние загрузки для конкретной модели
|
||||
getLoadingState(modelId: string): ModelLoadingState | undefined {
|
||||
return this.loadingStates.get(modelId);
|
||||
}
|
||||
|
||||
// Проверить, загружается ли какая-либо модель
|
||||
get isAnyModelLoading(): boolean {
|
||||
return Array.from(this.loadingStates.values()).some(
|
||||
(state) => state.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
// Получить все загружающиеся модели
|
||||
get loadingModels(): ModelLoadingState[] {
|
||||
return Array.from(this.loadingStates.values()).filter(
|
||||
(state) => state.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
// Получить общий прогресс всех загружающихся моделей
|
||||
get overallProgress(): number {
|
||||
const loadingModels = this.loadingModels;
|
||||
if (loadingModels.length === 0) return 100;
|
||||
|
||||
const totalProgress = loadingModels.reduce(
|
||||
(sum, model) => sum + model.progress,
|
||||
0
|
||||
);
|
||||
return Math.round(totalProgress / loadingModels.length);
|
||||
}
|
||||
|
||||
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
|
||||
get isSaveBlocked(): boolean {
|
||||
return this.isAnyModelLoading;
|
||||
}
|
||||
|
||||
// Очистить все состояния загрузки
|
||||
clearAll() {
|
||||
this.loadingStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const modelLoadingStore = new ModelLoadingStore();
|
||||
@@ -66,22 +66,41 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
routeStations: Record<string, any[]> = {};
|
||||
|
||||
getRoute = async (id: number) => {
|
||||
if (this.route[id]) return this.route[id];
|
||||
const response = await authInstance.get(`/route/${id}`);
|
||||
const stations = await authInstance.get(`/route/${id}/station`);
|
||||
|
||||
runInAction(() => {
|
||||
this.route[id] = response.data;
|
||||
this.routeStations[id] = stations.data;
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
setRouteStations = (routeId: number, stationId: number, data: any) => {
|
||||
this.routeStations[routeId] = this.routeStations[routeId]?.map((station) =>
|
||||
station.id === stationId ? { ...station, ...data } : station
|
||||
);
|
||||
};
|
||||
|
||||
saveRouteStations = async (routeId: number, stationId: number) => {
|
||||
await authInstance.patch(`/route/${routeId}/station`, {
|
||||
...this.routeStations[routeId]?.find(
|
||||
(station) => station.id === stationId
|
||||
),
|
||||
station_id: stationId,
|
||||
});
|
||||
};
|
||||
|
||||
editRouteData = {
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
center_latitude: 0,
|
||||
center_longitude: 0,
|
||||
center_latitude: "",
|
||||
center_longitude: "",
|
||||
governor_appeal: 0,
|
||||
id: 0,
|
||||
path: [] as number[][],
|
||||
@@ -99,10 +118,11 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
const response = await authInstance.patch(
|
||||
`/route/${id}`,
|
||||
this.editRouteData
|
||||
);
|
||||
const response = await authInstance.patch(`/route/${id}`, {
|
||||
...this.editRouteData,
|
||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||
center_longitude: parseFloat(this.editRouteData.center_longitude),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.route[id] = response.data;
|
||||
@@ -111,6 +131,20 @@ class RouteStore {
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
copyRouteAction = async (id: number) => {
|
||||
const response = await authInstance.post(`/route/${id}/copy`);
|
||||
|
||||
runInAction(() => {
|
||||
this.routes.data = [...this.routes.data, response.data];
|
||||
});
|
||||
};
|
||||
|
||||
selectedStationId = 0;
|
||||
|
||||
setSelectedStationId = (id: number) => {
|
||||
this.selectedStationId = id;
|
||||
};
|
||||
}
|
||||
|
||||
export const routeStore = new RouteStore();
|
||||
|
||||
48
src/shared/store/SelectedCityStore/index.ts
Normal file
48
src/shared/store/SelectedCityStore/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { City } from "../CityStore";
|
||||
|
||||
class SelectedCityStore {
|
||||
selectedCity: City | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
const storedCity = localStorage.getItem("selectedCity");
|
||||
if (storedCity) {
|
||||
try {
|
||||
this.selectedCity = JSON.parse(storedCity);
|
||||
} catch (error) {
|
||||
console.error("Error parsing stored city:", error);
|
||||
localStorage.removeItem("selectedCity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedCity = (city: City | null) => {
|
||||
runInAction(() => {
|
||||
this.selectedCity = city;
|
||||
if (city) {
|
||||
localStorage.setItem("selectedCity", JSON.stringify(city));
|
||||
} else {
|
||||
localStorage.removeItem("selectedCity");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
clearSelectedCity = () => {
|
||||
this.setSelectedCity(null);
|
||||
};
|
||||
|
||||
get selectedCityId() {
|
||||
return this.selectedCity?.id || null;
|
||||
}
|
||||
|
||||
get selectedCityName() {
|
||||
return this.selectedCity?.name || null;
|
||||
}
|
||||
}
|
||||
|
||||
export const selectedCityStore = new SelectedCityStore();
|
||||
@@ -97,15 +97,19 @@ class SightsStore {
|
||||
city: number,
|
||||
coordinates: { latitude: number; longitude: number }
|
||||
) => {
|
||||
const id = (
|
||||
await authInstance.post("/sight", {
|
||||
name: this.createSight[languageStore.language].name,
|
||||
address: this.createSight[languageStore.language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
})
|
||||
).data.id;
|
||||
const response = await authInstance.post("/sight", {
|
||||
name: this.createSight[languageStore.language].name,
|
||||
address: this.createSight[languageStore.language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.sights.push(response.data);
|
||||
});
|
||||
|
||||
const id = response.data.id;
|
||||
|
||||
const anotherLanguages = ["ru", "en", "zh"].filter(
|
||||
(language) => language !== languageStore.language
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
// Импорт функции сброса кешей карты
|
||||
// import { clearMapCaches } from "../../pages/MapPage";
|
||||
import {
|
||||
articlesStore,
|
||||
cityStore,
|
||||
countryStore,
|
||||
carrierStore,
|
||||
stationsStore,
|
||||
sightsStore,
|
||||
routeStore,
|
||||
vehicleStore,
|
||||
userStore,
|
||||
mediaStore,
|
||||
createSightStore,
|
||||
editSightStore,
|
||||
devicesStore,
|
||||
authStore,
|
||||
} from "@shared";
|
||||
|
||||
type Snapshot = {
|
||||
ID: string;
|
||||
@@ -17,6 +35,230 @@ class SnapshotStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// Функция для сброса всех кешей в приложении
|
||||
private clearAllCaches = () => {
|
||||
// Сброс кешей статей
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
articlesStore.articlePreview = {};
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
// Сброс кешей городов
|
||||
cityStore.cities = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
cityStore.ruCities = { data: [], loaded: false };
|
||||
cityStore.city = {};
|
||||
|
||||
// Сброс кешей стран
|
||||
countryStore.countries = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
// Сброс кешей перевозчиков
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
// Сброс кешей станций
|
||||
stationsStore.stationLists = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
stationsStore.stationPreview = {};
|
||||
|
||||
// Сброс кешей достопримечательностей
|
||||
sightsStore.sights = [];
|
||||
sightsStore.sight = null;
|
||||
|
||||
// Сброс кешей маршрутов
|
||||
routeStore.routes = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей транспорта
|
||||
vehicleStore.vehicles = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей пользователей
|
||||
userStore.users = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей медиа
|
||||
mediaStore.media = [];
|
||||
mediaStore.oneMedia = null;
|
||||
|
||||
// Сброс кешей создания и редактирования достопримечательностей
|
||||
createSightStore.sight = JSON.parse(
|
||||
JSON.stringify({
|
||||
city_id: 0,
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
thumbnail: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
preview_media: null,
|
||||
video_preview: null,
|
||||
ru: {
|
||||
name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
createSightStore.uploadMediaOpen = false;
|
||||
createSightStore.fileToUpload = null;
|
||||
createSightStore.needLeaveAgree = false;
|
||||
|
||||
editSightStore.sight = {
|
||||
common: {
|
||||
id: 0,
|
||||
city_id: 0,
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
thumbnail: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
preview_media: null,
|
||||
video_preview: null,
|
||||
},
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
};
|
||||
editSightStore.hasLoadedCommon = false;
|
||||
editSightStore.uploadMediaOpen = false;
|
||||
editSightStore.fileToUpload = null;
|
||||
editSightStore.needLeaveAgree = false;
|
||||
|
||||
// Сброс кешей устройств
|
||||
devicesStore.devices = [];
|
||||
devicesStore.uuid = null;
|
||||
devicesStore.sendSnapshotModalOpen = false;
|
||||
|
||||
// Сброс кешей авторизации (кроме токена)
|
||||
authStore.payload = null;
|
||||
authStore.error = null;
|
||||
authStore.isLoading = false;
|
||||
|
||||
// Сброс кешей карты (если они загружены)
|
||||
try {
|
||||
// Сбрасываем кеши mapStore если он доступен
|
||||
if (typeof window !== "undefined" && (window as any).mapStore) {
|
||||
(window as any).mapStore.routes = [];
|
||||
(window as any).mapStore.stations = [];
|
||||
(window as any).mapStore.sights = [];
|
||||
}
|
||||
|
||||
// Сбрасываем кеши MapService если он доступен
|
||||
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
|
||||
(window as any).mapServiceInstance.clearCaches();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Не удалось сбросить кеши карты:", error);
|
||||
}
|
||||
|
||||
// Сброс localStorage кешей (кроме токена авторизации)
|
||||
const token = localStorage.getItem("token");
|
||||
const rememberedEmail = localStorage.getItem("rememberedEmail");
|
||||
const rememberedPassword = localStorage.getItem("rememberedPassword");
|
||||
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Восстанавливаем важные данные
|
||||
if (token) localStorage.setItem("token", token);
|
||||
if (rememberedEmail)
|
||||
localStorage.setItem("rememberedEmail", rememberedEmail);
|
||||
if (rememberedPassword)
|
||||
localStorage.setItem("rememberedPassword", rememberedPassword);
|
||||
|
||||
// Сброс кешей карты (если они есть)
|
||||
const mapPositionKey = "mapPosition";
|
||||
const activeSectionKey = "mapActiveSection";
|
||||
if (localStorage.getItem(mapPositionKey)) {
|
||||
localStorage.removeItem(mapPositionKey);
|
||||
}
|
||||
if (localStorage.getItem(activeSectionKey)) {
|
||||
localStorage.removeItem(activeSectionKey);
|
||||
}
|
||||
|
||||
// Попытка очистить кеш браузера (если поддерживается)
|
||||
if ("caches" in window) {
|
||||
try {
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Кеш браузера не поддерживается:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Попытка очистить IndexedDB (если поддерживается)
|
||||
if ("indexedDB" in window) {
|
||||
try {
|
||||
indexedDB.databases().then((databases) => {
|
||||
return Promise.all(
|
||||
databases.map((db) => {
|
||||
if (db.name) {
|
||||
return indexedDB.deleteDatabase(db.name);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("IndexedDB не поддерживается:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getSnapshots = async () => {
|
||||
const response = await authInstance.get(`/snapshots`);
|
||||
|
||||
@@ -42,6 +284,10 @@ class SnapshotStore {
|
||||
};
|
||||
|
||||
restoreSnapshot = async (id: string) => {
|
||||
// Сначала сбрасываем все кеши
|
||||
this.clearAllCaches();
|
||||
|
||||
// Затем восстанавливаем снапшот
|
||||
await authInstance.post(`/snapshots/${id}/restore`);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ type Language = "ru" | "en" | "zh";
|
||||
type StationLanguageData = {
|
||||
name: string;
|
||||
system_name: string;
|
||||
description: string;
|
||||
address: string;
|
||||
loaded: boolean; // Indicates if this language's data has been loaded/modified
|
||||
};
|
||||
@@ -14,6 +13,7 @@ type StationLanguageData = {
|
||||
type StationCommonData = {
|
||||
city_id: number;
|
||||
direction: boolean;
|
||||
description: string;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -64,6 +64,10 @@ type Station = {
|
||||
};
|
||||
};
|
||||
|
||||
type CreateStationData = {
|
||||
[key in Language]: StationLanguageData;
|
||||
} & { common: StationCommonData };
|
||||
|
||||
class StationsStore {
|
||||
stations: Station[] = [];
|
||||
station: Station | null = null;
|
||||
@@ -98,21 +102,21 @@ class StationsStore {
|
||||
ru: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
@@ -120,6 +124,53 @@ class StationsStore {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
offset_x: 0,
|
||||
offset_y: 0,
|
||||
transfers: {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
createStationData: CreateStationData = {
|
||||
ru: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
@@ -172,9 +223,6 @@ class StationsStore {
|
||||
};
|
||||
|
||||
getEditStation = async (id: number) => {
|
||||
if (this.editStationData.ru.loaded) {
|
||||
return;
|
||||
}
|
||||
const ruResponse = await languageInstance("ru").get(`/station/${id}`);
|
||||
const enResponse = await languageInstance("en").get(`/station/${id}`);
|
||||
const zhResponse = await languageInstance("zh").get(`/station/${id}`);
|
||||
@@ -183,21 +231,21 @@ class StationsStore {
|
||||
ru: {
|
||||
name: ruResponse.data.name,
|
||||
system_name: ruResponse.data.system_name,
|
||||
description: ruResponse.data.description,
|
||||
|
||||
address: ruResponse.data.address,
|
||||
loaded: true,
|
||||
},
|
||||
en: {
|
||||
name: enResponse.data.name,
|
||||
system_name: enResponse.data.system_name,
|
||||
description: enResponse.data.description,
|
||||
|
||||
address: enResponse.data.address,
|
||||
loaded: true,
|
||||
},
|
||||
zh: {
|
||||
name: zhResponse.data.name,
|
||||
system_name: zhResponse.data.system_name,
|
||||
description: zhResponse.data.description,
|
||||
|
||||
address: zhResponse.data.address,
|
||||
loaded: true,
|
||||
},
|
||||
@@ -205,6 +253,7 @@ class StationsStore {
|
||||
city: ruResponse.data.city,
|
||||
city_id: ruResponse.data.city_id,
|
||||
direction: ruResponse.data.direction,
|
||||
description: ruResponse.data.description,
|
||||
icon: ruResponse.data.icon,
|
||||
latitude: ruResponse.data.latitude,
|
||||
longitude: ruResponse.data.longitude,
|
||||
@@ -240,7 +289,8 @@ class StationsStore {
|
||||
};
|
||||
|
||||
for (const language of ["ru", "en", "zh"] as const) {
|
||||
const { name, description, address } = this.editStationData[language];
|
||||
const { name, address } = this.editStationData[language];
|
||||
const description = this.editStationData.common.description;
|
||||
const response = await languageInstance(language).patch(
|
||||
`/station/${id}`,
|
||||
{
|
||||
@@ -277,7 +327,7 @@ class StationsStore {
|
||||
...station,
|
||||
name: response.data.name,
|
||||
system_name: response.data.system_name,
|
||||
description: response.data.description,
|
||||
description: description || "",
|
||||
address: response.data.address,
|
||||
...commonDataPayload,
|
||||
} as Station)
|
||||
@@ -336,35 +386,125 @@ class StationsStore {
|
||||
});
|
||||
};
|
||||
|
||||
createStation = async (
|
||||
name: string,
|
||||
systemName: string,
|
||||
direction: string
|
||||
setCreateCommonData = (data: Partial<StationCommonData>) => {
|
||||
this.createStationData.common = {
|
||||
...this.createStationData.common,
|
||||
...data,
|
||||
};
|
||||
};
|
||||
|
||||
setLanguageCreateStationData = (
|
||||
language: Language,
|
||||
data: Partial<StationLanguageData>
|
||||
) => {
|
||||
const response = await authInstance.post("/station", {
|
||||
station_name: name,
|
||||
system_name: systemName,
|
||||
direction,
|
||||
this.createStationData[language] = {
|
||||
...this.createStationData[language],
|
||||
...data,
|
||||
};
|
||||
};
|
||||
|
||||
createStation = async () => {
|
||||
const { language } = languageStore;
|
||||
let commonDataPayload: Partial<StationCommonData> = {
|
||||
city_id: this.createStationData.common.city_id,
|
||||
direction: this.createStationData.common.direction,
|
||||
icon: this.createStationData.common.icon,
|
||||
latitude: this.createStationData.common.latitude,
|
||||
longitude: this.createStationData.common.longitude,
|
||||
offset_x: this.createStationData.common.offset_x,
|
||||
offset_y: this.createStationData.common.offset_y,
|
||||
transfers: this.createStationData.common.transfers,
|
||||
city: this.createStationData.common.city,
|
||||
};
|
||||
|
||||
if (this.createStationData.common.icon === "") {
|
||||
delete commonDataPayload.icon;
|
||||
}
|
||||
|
||||
// First create station in Russian
|
||||
const { name, address } = this.createStationData[language];
|
||||
const description = this.createStationData.common.description;
|
||||
const response = await languageInstance(language).post("/station", {
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
...commonDataPayload,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.stations.push(response.data);
|
||||
const newStation = response.data as Station;
|
||||
if (!this.stationPreview[newStation.id]) {
|
||||
this.stationPreview[newStation.id] = {
|
||||
ru: { loaded: false, data: newStation },
|
||||
en: { loaded: false, data: newStation },
|
||||
zh: { loaded: false, data: newStation },
|
||||
};
|
||||
}
|
||||
this.stationPreview[newStation.id]["ru"] = {
|
||||
loaded: true,
|
||||
data: newStation,
|
||||
};
|
||||
this.stationPreview[newStation.id]["en"] = {
|
||||
loaded: true,
|
||||
data: newStation,
|
||||
this.stationLists[language].data.push(response.data);
|
||||
});
|
||||
|
||||
const stationId = response.data.id;
|
||||
|
||||
// Then update for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter(
|
||||
(lang) => lang !== language
|
||||
) as Language[]) {
|
||||
const { name, address } = this.createStationData[lang];
|
||||
const description = this.createStationData.common.description;
|
||||
const response = await languageInstance(lang).patch(
|
||||
`/station/${stationId}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
...commonDataPayload,
|
||||
}
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.stationLists[lang].data.push(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.createStationData = {
|
||||
ru: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
description: "",
|
||||
longitude: 0,
|
||||
offset_x: 0,
|
||||
offset_y: 0,
|
||||
transfers: {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Reset editStationData when navigating away or after saving
|
||||
@@ -373,21 +513,18 @@ class StationsStore {
|
||||
ru: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
loaded: false,
|
||||
},
|
||||
@@ -395,6 +532,7 @@ class StationsStore {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
|
||||
@@ -24,8 +24,6 @@ class UserStore {
|
||||
}
|
||||
|
||||
getUsers = async () => {
|
||||
if (this.users.loaded) return;
|
||||
|
||||
const response = await authInstance.get("/user");
|
||||
|
||||
runInAction(() => {
|
||||
@@ -35,7 +33,7 @@ class UserStore {
|
||||
};
|
||||
|
||||
getUser = async (id: number) => {
|
||||
if (this.user[id]) return;
|
||||
if (this.user[id]) return this.user[id];
|
||||
const response = await authInstance.get(`/user/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -35,8 +35,6 @@ class VehicleStore {
|
||||
}
|
||||
|
||||
getVehicles = async () => {
|
||||
if (this.vehicles.loaded) return;
|
||||
|
||||
const response = await languageInstance("ru").get(`/vehicle`);
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -14,3 +14,5 @@ export * from "./RouteStore";
|
||||
export * from "./UserStore";
|
||||
export * from "./CarrierStore";
|
||||
export * from "./StationsStore";
|
||||
export * from "./MenuStore";
|
||||
export * from "./SelectedCityStore";
|
||||
|
||||
143
src/shared/ui/LoadingSpinner/index.tsx
Normal file
143
src/shared/ui/LoadingSpinner/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
size?: number;
|
||||
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
|
||||
variant?: "circular" | "linear";
|
||||
showPercentage?: boolean;
|
||||
thickness?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
progress,
|
||||
message = "Загрузка...",
|
||||
size = 40,
|
||||
color = "primary",
|
||||
variant = "circular",
|
||||
showPercentage = true,
|
||||
thickness = 4,
|
||||
className,
|
||||
}) => {
|
||||
if (variant === "linear") {
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "100%", maxWidth: 300 }}>
|
||||
<LinearProgress
|
||||
variant={progress !== undefined ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
color={color}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
"& .MuiLinearProgress-bar": {
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
mt: 1,
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||
<CircularProgress
|
||||
size={size}
|
||||
color={color}
|
||||
variant={progress !== undefined ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
thickness={thickness}
|
||||
sx={{
|
||||
"& .MuiCircularProgress-circle": {
|
||||
strokeLinecap: "round",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: size * 0.25,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal file
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
|
||||
interface ModelLoadingIndicatorProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
isVisible?: boolean;
|
||||
variant?: "overlay" | "inline";
|
||||
size?: "small" | "medium" | "large";
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const ModelLoadingIndicator: React.FC<ModelLoadingIndicatorProps> = ({
|
||||
progress = 0,
|
||||
message = "Загрузка 3D модели...",
|
||||
isVisible = true,
|
||||
variant = "overlay",
|
||||
size = "medium",
|
||||
showDetails = true,
|
||||
}) => {
|
||||
const sizeConfig = {
|
||||
small: {
|
||||
spinnerSize: 32,
|
||||
fontSize: "0.75rem",
|
||||
progressBarWidth: 150,
|
||||
padding: 2,
|
||||
},
|
||||
medium: {
|
||||
spinnerSize: 48,
|
||||
fontSize: "0.875rem",
|
||||
progressBarWidth: 200,
|
||||
padding: 3,
|
||||
},
|
||||
large: {
|
||||
spinnerSize: 64,
|
||||
fontSize: "1rem",
|
||||
progressBarWidth: 250,
|
||||
padding: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size];
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const getProgressStage = (progress: number): string => {
|
||||
if (progress < 20) return "Инициализация...";
|
||||
if (progress < 40) return "Загрузка геометрии...";
|
||||
if (progress < 60) return "Обработка материалов...";
|
||||
if (progress < 80) return "Загрузка текстур...";
|
||||
if (progress < 95) return "Финализация...";
|
||||
if (progress === 100) return "Готово!";
|
||||
return "Загрузка...";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: currentSize.padding,
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Крутяшка с процентами */}
|
||||
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||
<CircularProgress
|
||||
size={currentSize.spinnerSize}
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
thickness={4}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: currentSize.spinnerSize * 0.25,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Линейный прогресс бар */}
|
||||
<Box sx={{ width: "100%", maxWidth: currentSize.progressBarWidth }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
color="primary"
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Основное сообщение */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: currentSize.fontSize,
|
||||
fontWeight: 500,
|
||||
maxWidth: 280,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
|
||||
{/* Детальная информация о прогрессе */}
|
||||
{showDetails && progress > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.8,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{getProgressStage(progress)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (variant === "overlay") {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
borderRadius: 1,
|
||||
border: "1px solid rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 200,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.02)",
|
||||
borderRadius: 2,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
77
src/widgets/CitySelector/index.tsx
Normal file
77
src/widgets/CitySelector/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent,
|
||||
Typography,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { cityStore, selectedCityStore } from "@shared";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
export const CitySelector: React.FC = observer(() => {
|
||||
const { getCities, cities } = cityStore;
|
||||
const { selectedCity, setSelectedCity } = selectedCityStore;
|
||||
|
||||
useEffect(() => {
|
||||
getCities("ru");
|
||||
}, []);
|
||||
|
||||
const handleCityChange = (event: SelectChangeEvent<string>) => {
|
||||
const cityId = event.target.value;
|
||||
if (cityId === "") {
|
||||
setSelectedCity(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
|
||||
if (city) {
|
||||
setSelectedCity(city);
|
||||
}
|
||||
};
|
||||
|
||||
const currentCities = cities["ru"].data;
|
||||
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<MapPin size={16} className="text-white" />
|
||||
<FormControl size="medium" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={selectedCity?.id?.toString() || ""}
|
||||
onChange={handleCityChange}
|
||||
displayEmpty
|
||||
sx={{
|
||||
height: "40px",
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "white",
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<Typography variant="body2">Выберите город</Typography>
|
||||
</MenuItem>
|
||||
{currentCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id?.toString()}>
|
||||
<Typography variant="body2">{city.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -3,21 +3,25 @@ import { Button } from "@mui/material";
|
||||
export const DeleteModal = ({
|
||||
onDelete,
|
||||
onCancel,
|
||||
edit = false,
|
||||
open,
|
||||
}: {
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
edit?: boolean;
|
||||
open: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30 transition-all duration-300 ${
|
||||
className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-1000000000000 bg-black/30 transition-all duration-300 ${
|
||||
open ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-white p-4 w-100 rounded-lg flex flex-col gap-4 items-center">
|
||||
<p className="text-black w-100 text-center">
|
||||
Вы уверены, что хотите удалить этот элемент?
|
||||
{`Вы уверены, что хотите ${
|
||||
edit ? "убрать" : "удалить"
|
||||
} этот элемент?`}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button variant="contained" color="error" onClick={onDelete}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import { Check, RotateCcw, X } from "lucide-react";
|
||||
import { Check, Copy, RotateCcw, X } from "lucide-react";
|
||||
import {
|
||||
authInstance,
|
||||
devicesStore,
|
||||
@@ -18,6 +18,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { Button, Checkbox, Typography } from "@mui/material";
|
||||
import { Vehicle } from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export type ConnectedDevice = string;
|
||||
|
||||
@@ -116,6 +117,7 @@ export const DevicesTable = observer(() => {
|
||||
const { snapshots, getSnapshots } = snapshotStore;
|
||||
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
|
||||
const { devices } = devicesStore;
|
||||
const navigate = useNavigate();
|
||||
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
|
||||
|
||||
// Transform the raw devices data into rows suitable for the table
|
||||
@@ -172,6 +174,7 @@ export const DevicesTable = observer(() => {
|
||||
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
|
||||
try {
|
||||
await authInstance.post(`/devices/${uuid}/request-status`);
|
||||
await getVehicles();
|
||||
await getDevices(); // Refresh devices to show updated status
|
||||
} catch (error) {
|
||||
console.error(`Error requesting status for device ${uuid}:`, error);
|
||||
@@ -182,13 +185,24 @@ export const DevicesTable = observer(() => {
|
||||
const handleSendSnapshotAction = async (snapshotId: string) => {
|
||||
if (selectedDeviceUuids.length === 0) return;
|
||||
|
||||
const send = async (deviceUuid: string) => {
|
||||
try {
|
||||
await authInstance.post(
|
||||
`/devices/${deviceUuid}/force-snapshot-update`,
|
||||
{
|
||||
snapshot_id: snapshotId,
|
||||
}
|
||||
);
|
||||
toast.success(`Снапшот отправлен на устройство `);
|
||||
} catch (error) {
|
||||
console.error(`Error sending snapshot to device ${deviceUuid}:`, error);
|
||||
toast.error(`Не удалось отправить снапшот на устройство`);
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Create an array of promises for all snapshot requests
|
||||
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
|
||||
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`);
|
||||
return authInstance.post(`/devices/${deviceUuid}/force-snapshot`, {
|
||||
snapshot_id: snapshotId,
|
||||
});
|
||||
return send(deviceUuid);
|
||||
});
|
||||
|
||||
// Wait for all promises to settle (either resolve or reject)
|
||||
@@ -209,6 +223,16 @@ export const DevicesTable = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||
<div className="flex justify-end p-3 gap-2 ">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => navigate("/vehicle/create")}
|
||||
>
|
||||
Добавить устройство
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end p-3 gap-2">
|
||||
<Button
|
||||
variant="outlined" // Changed to outlined for distinction
|
||||
@@ -269,16 +293,34 @@ export const DevicesTable = observer(() => {
|
||||
'input[type="checkbox"]'
|
||||
) === null
|
||||
) {
|
||||
handleSelectDevice(
|
||||
{
|
||||
target: {
|
||||
checked: !selectedDeviceUuids.includes(
|
||||
row.device_uuid ?? ""
|
||||
),
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
|
||||
row.device_uuid ?? ""
|
||||
);
|
||||
if (event.shiftKey) {
|
||||
if (row.device_uuid) {
|
||||
navigator.clipboard
|
||||
.writeText(row.device_uuid)
|
||||
.then(() => {
|
||||
toast.success(`UUID скопирован`);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Не удалось скопировать UUID");
|
||||
});
|
||||
} else {
|
||||
toast.warning("Устройство не имеет UUID");
|
||||
}
|
||||
}
|
||||
|
||||
// Only toggle checkbox if Shift key is not pressed
|
||||
if (!event.shiftKey) {
|
||||
handleSelectDevice(
|
||||
{
|
||||
target: {
|
||||
checked: !selectedDeviceUuids.includes(
|
||||
row.device_uuid ?? ""
|
||||
),
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
|
||||
row.device_uuid ?? ""
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
@@ -356,7 +398,6 @@ export const DevicesTable = observer(() => {
|
||||
devices.find((device) => device === row.device_uuid)
|
||||
) {
|
||||
await handleReloadStatus(row.device_uuid);
|
||||
await getDevices();
|
||||
toast.success("Статус устройства обновлен");
|
||||
} else {
|
||||
toast.error("Нет связи с устройством");
|
||||
@@ -371,6 +412,14 @@ export const DevicesTable = observer(() => {
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(row.device_uuid ?? "");
|
||||
toast.success("UUID скопирован");
|
||||
}}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { editSightStore } from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
interface ImageUploadCardProps {
|
||||
title: string;
|
||||
imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd";
|
||||
imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image";
|
||||
imageUrl: string | null | undefined;
|
||||
onImageClick: () => void;
|
||||
onDeleteImageClick: () => void;
|
||||
@@ -35,6 +35,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
console.log("isDragOver");
|
||||
}
|
||||
}, [isDragOver]);
|
||||
|
||||
// --- Click to select file ---
|
||||
const handleZoneClick = () => {
|
||||
// Trigger the hidden file input click
|
||||
@@ -46,10 +47,16 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setFileToUpload(file);
|
||||
setUploadMediaOpen(true);
|
||||
if (imageKey && setHardcodeType) {
|
||||
setHardcodeType(imageKey);
|
||||
if (file.type.startsWith("image/") && file.type !== "image/gif") {
|
||||
setFileToUpload(file);
|
||||
setUploadMediaOpen(true);
|
||||
if (imageKey && setHardcodeType) {
|
||||
setHardcodeType(imageKey);
|
||||
}
|
||||
} else if (file.type === "image/gif") {
|
||||
toast.error("GIF файлы не поддерживаются");
|
||||
} else {
|
||||
toast.error("Пожалуйста, выберите изображение");
|
||||
}
|
||||
}
|
||||
// Reset the input value so selecting the same file again triggers change
|
||||
@@ -78,9 +85,11 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (file.type.startsWith("image/") && file.type !== "image/gif") {
|
||||
setFileToUpload(file);
|
||||
setUploadMediaOpen(true);
|
||||
} else if (file.type === "image/gif") {
|
||||
toast.error("GIF файлы не поддерживаются");
|
||||
} else {
|
||||
toast.error("Пожалуйста, выберите изображение");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user