Compare commits
35 Commits
27cb644242
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b48ade2f1 | |||
| b0fdf03cc6 | |||
|
|
349c7009c6 | ||
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 | |||
| a357994025 | |||
| 7382a85082 | |||
| db64beb3ee | |||
|
|
1abd6b30a4 | ||
|
|
b25df42960 | ||
|
34ba3c1db0
|
|||
| 4f038551a2 | |||
| 470a58a3fa | |||
| 89d7fc2748 | |||
| 97f95fc394 | |||
| bf117ef048 | |||
| ced3067915 | |||
| a908c63771 | |||
| 06eafee3f4 | |||
| 717031cd7a | |||
|
2d4a1e169b
|
|||
| e2547cb571 | |||
| 78800ee2ae | |||
| d415441af8 | |||
| 32a7cb44d1 | |||
| 481385c2f4 | |||
| 2117a6836e | |||
| f49caf3ec8 | |||
| 300ff262ce |
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
@@ -0,0 +1,32 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and yarn.lock
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration (optional, can be added later if needed)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx server
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Variables
|
||||
IMAGE_NAME = white-nights-admin-panel
|
||||
IMAGE_TAG = latest
|
||||
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
ARCHIVE_NAME = white-nights-admin-panel-image.zip
|
||||
|
||||
# Default target
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build-image - Build Docker image"
|
||||
@echo " make export-image - Build Docker image and export it to a zip archive"
|
||||
@echo " make clean - Remove Docker image and zip archive"
|
||||
@echo " make help - Show this help message"
|
||||
|
||||
# Build Docker image
|
||||
.PHONY: build-image
|
||||
build-image:
|
||||
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Export Docker image to zip archive
|
||||
.PHONY: export-image
|
||||
export-image: build-image
|
||||
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
|
||||
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
|
||||
@echo "Image exported successfully to $(ARCHIVE_NAME)"
|
||||
|
||||
# Clean up
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "Removing Docker image and zip archive"
|
||||
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
|
||||
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
|
||||
@echo "Clean up completed"
|
||||
|
||||
# Default target when no arguments provided
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon_ship.png" />
|
||||
<link rel="icon" type="image/svg" href="/favicon_ship.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Белые ночи</title>
|
||||
</head>
|
||||
|
||||
3488
package-lock.json
generated
12
package.json
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"axios": "^1.9.0",
|
||||
"easymde": "^2.20.0",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mobx": "^6.13.7",
|
||||
"mobx-react-lite": "^4.1.0",
|
||||
@@ -30,11 +32,10 @@
|
||||
"path": "^0.12.7",
|
||||
"pixi.js": "^8.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-sphere-viewer": "^6.2.3",
|
||||
"react-router": "^7.9.4",
|
||||
"react-router-dom": "^7.6.1",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
@@ -53,8 +54,11 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-svgr": "^4.5.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 2.3 KiB |
3
public/favicon_ship.svg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
21
public/sight_icon.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="53" height="49" viewBox="0 0 53 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 19.9296H52.7174V15.9968L26.3662 0L0 15.9968V19.9296ZM26.3659 3.75713L4.75331 16.8616H47.9636L26.3659 3.75713Z" fill="#A6A6A6"/>
|
||||
<path d="M52.7174 45.4072H0V48.3587H52.7174V45.4072Z" fill="#A6A6A6"/>
|
||||
<path d="M50.0742 41.4756H2.64355V44.427H50.0742V41.4756Z" fill="#A6A6A6"/>
|
||||
<path d="M9.46312 21.6035H5.49805V39.0244H9.46312V21.6035Z" fill="#A6A6A6"/>
|
||||
<path d="M11.4448 39.4316H3.51465V40.5827H11.4448V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M4.40104 20.6592C4.10066 20.6592 3.86035 20.8953 3.86035 21.1904C3.86035 21.4856 4.10066 21.7217 4.40104 21.7217C4.70143 21.7217 4.94173 21.4856 4.94173 21.1904H10.0182C10.0182 21.4856 10.2585 21.7217 10.5589 21.7217C10.8593 21.7217 11.0996 21.4856 11.0996 21.1904C11.0996 20.8953 10.8593 20.6592 10.5589 20.6592H4.40104Z" fill="#A6A6A6"/>
|
||||
<path d="M22.0979 21.6035H18.1328V39.0244H22.0979V21.6035Z" fill="#A6A6A6"/>
|
||||
<path d="M24.0815 39.4316H16.1514V40.5827H24.0815V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M17.0358 20.6592C16.7354 20.6592 16.4951 20.8953 16.4951 21.1904C16.4951 21.4856 16.7354 21.7217 17.0358 21.7217C17.3362 21.7217 17.5765 21.4856 17.5765 21.1904H22.653C22.653 21.4856 22.8933 21.7217 23.1937 21.7217C23.4941 21.7217 23.7344 21.4856 23.7344 21.1904C23.7344 20.8953 23.4941 20.6592 23.1937 20.6592H17.0358Z" fill="#A6A6A6"/>
|
||||
<path d="M34.7414 21.6035H30.7764V39.0244H34.7414V21.6035Z" fill="#A6A6A6"/>
|
||||
<path d="M36.7231 39.4316H28.793V40.5827H36.7231V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M29.6794 20.6592C29.379 20.6592 29.1387 20.8953 29.1387 21.1904C29.1387 21.4856 29.379 21.7217 29.6794 21.7217C29.9797 21.7217 30.2201 21.4856 30.2201 21.1904H35.2965C35.2965 21.4856 35.5369 21.7217 35.8372 21.7217C36.1376 21.7217 36.3779 21.4856 36.3779 21.1904C36.3779 20.8953 36.1376 20.6592 35.8372 20.6592H29.6794Z" fill="#A6A6A6"/>
|
||||
<path d="M47.3762 21.6045H43.4111V39.0254H47.3762V21.6045Z" fill="#A6A6A6"/>
|
||||
<path d="M49.3598 39.4316H41.4297V40.5827H49.3598V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M42.3141 20.6592C42.0137 20.6592 41.7734 20.8953 41.7734 21.1904C41.7734 21.4856 42.0137 21.7217 42.3141 21.7217C42.6145 21.7217 42.8548 21.4856 42.8548 21.1904H47.9313C47.9313 21.4856 48.1716 21.7217 48.472 21.7217C48.7724 21.7217 49.0127 21.4856 49.0127 21.1904C49.0127 20.8953 48.7724 20.6592 48.472 20.6592H42.3141Z" fill="#A6A6A6"/>
|
||||
<path d="M26.8478 9.42308C26.8478 9.18696 26.6151 8.99512 26.3297 8.99512C26.0443 8.99512 25.8115 9.18696 25.8115 9.42308V9.76249C25.8115 10.0429 26.0443 10.2716 26.3297 10.2716C26.6151 10.2716 26.8478 10.0429 26.8478 9.76249V9.42308Z" fill="#A6A6A6"/>
|
||||
<path d="M19.5098 11.6514C19.2695 11.6514 19.0742 11.8801 19.0742 12.1679C19.0742 12.4483 19.2695 12.6844 19.5098 12.6844H20.4109C20.6963 12.6844 20.9366 12.4556 20.9366 12.1679C20.9366 11.8875 20.7038 11.6514 20.4109 11.6514H19.5098Z" fill="#A6A6A6"/>
|
||||
<path d="M32.2022 11.6514C31.9619 11.6514 31.7666 11.8801 31.7666 12.1679C31.7666 12.4483 31.9619 12.6844 32.2022 12.6844H33.1033C33.3887 12.6844 33.629 12.4556 33.629 12.1679C33.629 11.8875 33.3962 11.6514 33.1033 11.6514H32.2022Z" fill="#A6A6A6"/>
|
||||
<path d="M27.6188 11.1644L26.973 10.7586C26.973 10.7586 26.8979 10.7217 26.8528 10.7217H25.8015C25.7564 10.7217 25.7189 10.7364 25.6813 10.7586L25.0355 11.1644C24.9679 11.2087 24.9304 11.2751 24.9304 11.3489V11.8211C24.9304 11.8654 24.9454 11.9096 24.9679 11.9465L25.5311 12.7656C25.5762 12.832 25.5837 12.9057 25.5537 12.9795L24.9454 14.3372C24.9454 14.3372 24.9229 14.3962 24.9229 14.4257V15.776C24.9229 15.9015 25.0205 15.9974 25.1481 15.9974H27.4911C27.6188 15.9974 27.7164 15.9015 27.7164 15.776V14.4257C27.7164 14.4257 27.7164 14.3667 27.6939 14.3372L27.0856 12.9795C27.0556 12.9131 27.0631 12.832 27.1081 12.7656L27.6714 11.9465C27.6714 11.9465 27.7089 11.8654 27.7089 11.8211V11.3489C27.7089 11.2751 27.6714 11.2013 27.6038 11.1644H27.6188Z" fill="#A6A6A6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
|
||||
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "background.default",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={64} color="#f44336" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Упс! Что-то пошло не так
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||
перезагрузить страницу или вернуться на главную.
|
||||
</Typography>
|
||||
|
||||
{this.state.error?.message && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
backgroundColor: "error.light",
|
||||
color: "error.contrastText",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||
>
|
||||
Информация об ошибке:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Home size={16} />}
|
||||
onClick={this.handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
На главную
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={this.handleReset}
|
||||
size="large"
|
||||
>
|
||||
Перезагрузить
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { Router } from "./router";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
SnapshotListPage,
|
||||
CarrierListPage,
|
||||
StationListPage,
|
||||
VehicleListPage,
|
||||
// VehicleListPage,
|
||||
ArticleListPage,
|
||||
CityPreviewPage,
|
||||
CountryPreviewPage,
|
||||
VehiclePreviewPage,
|
||||
CarrierPreviewPage,
|
||||
|
||||
// CountryPreviewPage,
|
||||
// VehiclePreviewPage,
|
||||
// CarrierPreviewPage,
|
||||
SnapshotCreatePage,
|
||||
CountryCreatePage,
|
||||
CityCreatePage,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
UserEditPage,
|
||||
VehicleEditPage,
|
||||
// VehicleEditPage,
|
||||
CarrierEditPage,
|
||||
StationCreatePage,
|
||||
StationPreviewPage,
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
RouteCreatePage,
|
||||
RoutePreview,
|
||||
RouteEditPage,
|
||||
ArticlePreviewPage,
|
||||
CountryAddPage,
|
||||
} from "@pages";
|
||||
import { authStore, createSightStore, editSightStore } from "@shared";
|
||||
import { Layout } from "@widgets";
|
||||
@@ -56,7 +58,7 @@ import {
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/sight" replace />;
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -68,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (location.pathname === "/") {
|
||||
return <Navigate to="/sight" replace />;
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -133,12 +135,13 @@ const router = createBrowserRouter([
|
||||
// Country
|
||||
{ path: "country", element: <CountryListPage /> },
|
||||
{ path: "country/create", element: <CountryCreatePage /> },
|
||||
{ path: "country/:id", element: <CountryPreviewPage /> },
|
||||
{ path: "country/add", element: <CountryAddPage /> },
|
||||
// { path: "country/:id", element: <CountryPreviewPage /> },
|
||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||
// City
|
||||
{ path: "city", element: <CityListPage /> },
|
||||
{ path: "city/create", element: <CityCreatePage /> },
|
||||
{ path: "city/:id", element: <CityPreviewPage /> },
|
||||
// { path: "city/:id", element: <CityPreviewPage /> },
|
||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||
// Route
|
||||
{ path: "route", element: <RouteListPage /> },
|
||||
@@ -156,7 +159,7 @@ const router = createBrowserRouter([
|
||||
// Carrier
|
||||
{ path: "carrier", element: <CarrierListPage /> },
|
||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||
// Station
|
||||
{ path: "station", element: <StationListPage /> },
|
||||
@@ -164,13 +167,13 @@ const router = createBrowserRouter([
|
||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||
// Vehicle
|
||||
{ path: "vehicle", element: <VehicleListPage /> },
|
||||
// { path: "vehicle", element: <VehicleListPage /> },
|
||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
// Article
|
||||
{ path: "article", element: <ArticleListPage /> },
|
||||
// { path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
// { path: "media/create", element: <CreateMediaPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -5,6 +5,7 @@ export interface NavigationItem {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { NavigationItem } from "../model";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authStore } from "@shared";
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: NavigationItem;
|
||||
open: boolean;
|
||||
onClick?: () => void;
|
||||
isNested?: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
@@ -22,15 +25,31 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
open,
|
||||
onClick,
|
||||
isNested = false,
|
||||
onDrawerOpen,
|
||||
}) => {
|
||||
const Icon = item.icon;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const { payload } = authStore;
|
||||
|
||||
// @ts-ignore
|
||||
const isAdmin = payload?.is_admin || false;
|
||||
|
||||
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
||||
|
||||
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (item.id === "all" && !open) {
|
||||
onDrawerOpen?.();
|
||||
}
|
||||
if (item.nestedItems) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else if (onClick) {
|
||||
@@ -58,7 +77,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
isNested && {
|
||||
pl: 4,
|
||||
pl: open ? 4 : 2.5,
|
||||
},
|
||||
isActive && {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.08)",
|
||||
@@ -84,7 +103,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Icon />
|
||||
{Icon ? <Icon /> : <Plus />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
@@ -102,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{item.nestedItems &&
|
||||
{filteredNestedItems &&
|
||||
filteredNestedItems.length > 0 &&
|
||||
open &&
|
||||
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{item.nestedItems && (
|
||||
<Collapse in={isExpanded && open} timeout="auto" unmountOnExit>
|
||||
{filteredNestedItems && filteredNestedItems.length > 0 && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.nestedItems.map((nestedItem) => (
|
||||
{filteredNestedItems.map((nestedItem) => (
|
||||
<NavigationItemComponent
|
||||
key={nestedItem.id}
|
||||
item={nestedItem}
|
||||
|
||||
@@ -1,34 +1,62 @@
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { NAVIGATION_ITEMS } from "@shared";
|
||||
import { authStore, NAVIGATION_ITEMS } from "@shared";
|
||||
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const NavigationList = ({ open }: { open: boolean }) => {
|
||||
const primaryItems = NAVIGATION_ITEMS.primary;
|
||||
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
||||
interface NavigationListProps {
|
||||
open: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{secondaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const NavigationList = observer(
|
||||
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const { payload } = authStore;
|
||||
// @ts-ignore
|
||||
const isAdmin = Boolean(payload?.is_admin) || false;
|
||||
|
||||
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
||||
if (item.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
if (item.nestedItems && item.nestedItems.length > 0) {
|
||||
return item.nestedItems.some((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{NAVIGATION_ITEMS.secondary.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
33
src/pages/Article/ArticleCreatePage/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore } from "@shared";
|
||||
|
||||
const ArticleCreatePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { articleData } = articlesStore;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<h1 className="text-3xl break-words">
|
||||
{articleData?.ru?.heading || "Создание статьи"}
|
||||
</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleCreatePage;
|
||||
49
src/pages/Article/ArticleEditPage/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const ArticleEditPage: React.FC = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
|
||||
const { articleData, getArticle } = articlesStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
// Fetch data for all languages
|
||||
getArticle(parseInt(id), "ru");
|
||||
getArticle(parseInt(id), "en");
|
||||
getArticle(parseInt(id), "zh");
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<h1 className="text-3xl break-words">
|
||||
{articleData?.ru?.heading || "Редактирование статьи"}
|
||||
</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArticleEditPage;
|
||||
@@ -1,20 +1,30 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Eye } from "lucide-react";
|
||||
import { Trash2, Eye, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const ArticleListPage = observer(() => {
|
||||
const { articleList, getArticleList } = articlesStore;
|
||||
const { articleList, getArticleList, deleteArticles } = articlesStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getArticleList();
|
||||
const fetchArticles = async () => {
|
||||
setIsLoading(true);
|
||||
await getArticleList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchArticles();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -22,11 +32,21 @@ export const ArticleListPage = observer(() => {
|
||||
field: "heading",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<div className="flex h-full gap-7 items-center">
|
||||
<Minus size={20} className="text-red-500" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -59,18 +79,53 @@ export const ArticleListPage = observer(() => {
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Статьи</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box
|
||||
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteArticles([parseInt(rowId)]);
|
||||
getArticleList();
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
@@ -81,6 +136,19 @@ export const ArticleListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await deleteArticles(ids);
|
||||
getArticleList();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
85
src/pages/Article/ArticlePreviewPage/PreviewLeftWidget.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Paper, Box, Typography } from "@mui/material";
|
||||
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const PreviewLeftWidget = observer(() => {
|
||||
const { articleMedia, articleData } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
width: "100%",
|
||||
minWidth: 320,
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
minHeight: 100,
|
||||
padding: "3px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
"& img": {
|
||||
borderTopLeftRadius: "10px",
|
||||
borderTopRightRadius: "10px",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{articleMedia && <MediaViewer media={articleMedia} fullWidth />}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
color: "white",
|
||||
margin: "5px 0px 5px 0px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h2"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
}}
|
||||
>
|
||||
{articleData?.[language]?.heading || "Название информации"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{articleData?.[language]?.body && (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
maxHeight: "300px",
|
||||
overflowY: "scroll",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent value={articleData?.[language]?.body} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
139
src/pages/Article/ArticlePreviewPage/PreviewRightWidget.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Paper, Box, Typography } from "@mui/material";
|
||||
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
|
||||
export const PreviewRightWidget = observer(() => {
|
||||
const { articleData, articleMedia } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const article = articleData?.[language];
|
||||
if (!article) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className="flex-1 flex flex-col max-w-[500px]"
|
||||
sx={{
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={2}
|
||||
>
|
||||
<Box
|
||||
className="overflow-hidden"
|
||||
sx={{
|
||||
width: "100%",
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{articleMedia ? (
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
padding: "2px 2px 0px 2px",
|
||||
"& img": {
|
||||
borderTopLeftRadius: "10px",
|
||||
borderTopRightRadius: "10px",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MediaViewer media={articleMedia} fullWidth fullHeight />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="white" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
wordBreak: "break-word",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderBottom: "1px solid #A89F90",
|
||||
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{article.heading || "Выберите статью"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
minHeight: "200px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "scroll",
|
||||
background:
|
||||
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{article.body ? (
|
||||
<ReactMarkdownComponent value={article.body} />
|
||||
) : (
|
||||
<Typography
|
||||
color="rgba(255,255,255,0.7)"
|
||||
sx={{ textAlign: "center", mt: 4 }}
|
||||
>
|
||||
Предпросмотр статьи появится здесь
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* @ts-ignore */}
|
||||
{articleData?.right && articleData?.right.length > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{articleData.right.map((a, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="inline-block text-left text-xs text-white"
|
||||
>
|
||||
{a.heading}
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
57
src/pages/Article/ArticlePreviewPage/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { PreviewLeftWidget } from "./PreviewLeftWidget";
|
||||
import { PreviewRightWidget } from "./PreviewRightWidget";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export const ArticlePreviewPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getArticle(Number(id), language);
|
||||
await getArticleMedia(Number(id));
|
||||
await getArticlePreview(Number(id));
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
p: 2,
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "320px" }}>
|
||||
<PreviewLeftWidget />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: "500px" }}>
|
||||
<PreviewRightWidget />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./ArticleListPage";
|
||||
export * from "./ArticlePreviewPage";
|
||||
|
||||
@@ -12,41 +12,61 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore, cityStore, mediaStore } from "@shared";
|
||||
import {
|
||||
carrierStore,
|
||||
cityStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [shortName, setShortName] = useState("");
|
||||
const [cityId, setCityId] = useState<number | null>(null);
|
||||
const [main_color, setMainColor] = useState("#000000");
|
||||
const [left_color, setLeftColor] = useState("#ffffff");
|
||||
const [right_color, setRightColor] = useState("#ff0000");
|
||||
const [slogan, setSlogan] = useState("");
|
||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const { selectedCityId } = useSelectedCity();
|
||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cityStore.getCities("ru");
|
||||
mediaStore.getMedia();
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && !createCarrierData.city_id) {
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await carrierStore.createCarrier(
|
||||
fullName,
|
||||
shortName,
|
||||
cityStore.cities.ru.find((c) => c.id === cityId)?.name!,
|
||||
cityId!,
|
||||
main_color,
|
||||
left_color,
|
||||
right_color,
|
||||
slogan,
|
||||
selectedMediaId!
|
||||
);
|
||||
await carrierStore.createCarrier();
|
||||
|
||||
toast.success("Перевозчик успешно создан");
|
||||
navigate("/carrier");
|
||||
} catch (error) {
|
||||
@@ -56,8 +76,30 @@ export const CarrierCreatePage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setSelectedMediaId(media.id);
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = selectedMediaId
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -69,15 +111,28 @@ export const CarrierCreatePage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">Создание перевозчика</h1>
|
||||
</div>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={cityId || ""}
|
||||
value={createCarrierData.city_id || ""}
|
||||
label="Город"
|
||||
required
|
||||
onChange={(e) => setCityId(e.target.value as number)}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
e.target.value as number,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities.ru.map((city) => (
|
||||
{cityStore.cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -88,100 +143,83 @@ export const CarrierCreatePage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Полное название"
|
||||
value={fullName}
|
||||
value={createCarrierData[language].full_name}
|
||||
required
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
e.target.value,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Короткое название"
|
||||
value={shortName}
|
||||
value={createCarrierData[language].short_name}
|
||||
required
|
||||
onChange={(e) => setShortName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 w-full ">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Основной цвет"
|
||||
value={main_color}
|
||||
className="flex-1 w-full"
|
||||
onChange={(e) => setMainColor(e.target.value)}
|
||||
type="color"
|
||||
sx={{
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Цвет левого виджета"
|
||||
value={left_color}
|
||||
className="flex-1 w-full"
|
||||
onChange={(e) => setLeftColor(e.target.value)}
|
||||
type="color"
|
||||
sx={{
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Цвет правого виджета"
|
||||
value={right_color}
|
||||
className="flex-1 w-full"
|
||||
onChange={(e) => setRightColor(e.target.value)}
|
||||
type="color"
|
||||
sx={{
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={slogan}
|
||||
onChange={(e) => setSlogan(e.target.value)}
|
||||
value={createCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Логотип</InputLabel>
|
||||
<Select
|
||||
value={selectedMediaId || ""}
|
||||
label="Логотип"
|
||||
required
|
||||
onChange={(e) => setSelectedMediaId(e.target.value as string)}
|
||||
>
|
||||
{mediaStore.media
|
||||
.filter((media) => media.media_type === 3)
|
||||
.map((media) => (
|
||||
<MenuItem key={media.id} value={media.id}>
|
||||
{media.media_name || media.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedMediaId && (
|
||||
<div className="w-32 h-32">
|
||||
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setSelectedMediaId(null);
|
||||
setActiveMenuType(null);
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -190,7 +228,10 @@ export const CarrierCreatePage = observer(() => {
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
|
||||
isLoading ||
|
||||
!createCarrierData[language].full_name ||
|
||||
!createCarrierData[language].short_name ||
|
||||
!createCarrierData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -200,6 +241,28 @@ export const CarrierCreatePage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createCarrierData[language].full_name}
|
||||
contextType="carrier"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,35 +12,70 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore, cityStore, mediaStore } from "@shared";
|
||||
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CarrierEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { carrier, getCarrier, setEditCarrierData, editCarrierData } =
|
||||
carrierStore;
|
||||
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getCarrier(Number(id));
|
||||
setEditCarrierData(
|
||||
carrier?.[Number(id)]?.full_name as string,
|
||||
carrier?.[Number(id)]?.short_name as string,
|
||||
carrier?.[Number(id)]?.city as string,
|
||||
carrier?.[Number(id)]?.city_id as number,
|
||||
carrier?.[Number(id)]?.main_color as string,
|
||||
carrier?.[Number(id)]?.left_color as string,
|
||||
carrier?.[Number(id)]?.right_color as string,
|
||||
carrier?.[Number(id)]?.slogan as string,
|
||||
carrier?.[Number(id)]?.logo as string
|
||||
);
|
||||
cityStore.getCities("ru");
|
||||
await cityStore.getCities("ru");
|
||||
await cityStore.getCities("en");
|
||||
await cityStore.getCities("zh");
|
||||
const carrierData = await getCarrier(Number(id));
|
||||
|
||||
if (carrierData) {
|
||||
setEditCarrierData(
|
||||
carrierData.ru?.full_name || "",
|
||||
carrierData.ru?.short_name || "",
|
||||
carrierData.ru?.city_id || 0,
|
||||
carrierData.ru?.slogan || "",
|
||||
carrierData.ru?.logo || "",
|
||||
"ru"
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.en?.full_name || "",
|
||||
carrierData.en?.short_name || "",
|
||||
carrierData.en?.city_id || 0,
|
||||
carrierData.en?.slogan || "",
|
||||
carrierData.en?.logo || "",
|
||||
"en"
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.zh?.full_name || "",
|
||||
carrierData.zh?.short_name || "",
|
||||
carrierData.zh?.city_id || 0,
|
||||
carrierData.zh?.slogan || "",
|
||||
carrierData.zh?.logo || "",
|
||||
"zh"
|
||||
);
|
||||
}
|
||||
|
||||
mediaStore.getMedia();
|
||||
})();
|
||||
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, [id]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
@@ -56,8 +91,29 @@ export const CarrierEditPage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCarrierData.logo
|
||||
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -68,6 +124,9 @@ export const CarrierEditPage = observer(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
@@ -77,19 +136,16 @@ export const CarrierEditPage = observer(() => {
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
Number(e.target.value),
|
||||
editCarrierData.main_color,
|
||||
editCarrierData.left_color,
|
||||
editCarrierData.right_color,
|
||||
editCarrierData.slogan,
|
||||
editCarrierData.logo
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities.ru.map((city) => (
|
||||
{cityStore.cities["ru"].data?.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -100,19 +156,16 @@ export const CarrierEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Полное название"
|
||||
value={editCarrierData.full_name}
|
||||
value={editCarrierData[language].full_name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
e.target.value,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData.main_color,
|
||||
editCarrierData.left_color,
|
||||
editCarrierData.right_color,
|
||||
editCarrierData.slogan,
|
||||
editCarrierData.logo
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -120,166 +173,57 @@ export const CarrierEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Короткое название"
|
||||
value={editCarrierData.short_name}
|
||||
value={editCarrierData[language].short_name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
editCarrierData.city,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData.main_color,
|
||||
editCarrierData.left_color,
|
||||
editCarrierData.right_color,
|
||||
editCarrierData.slogan,
|
||||
editCarrierData.logo
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 w-full">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Основной цвет"
|
||||
value={editCarrierData.main_color}
|
||||
className="flex-1 w-full"
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData.city_id,
|
||||
e.target.value,
|
||||
editCarrierData.left_color,
|
||||
editCarrierData.right_color,
|
||||
editCarrierData.slogan,
|
||||
editCarrierData.logo
|
||||
)
|
||||
}
|
||||
type="color"
|
||||
sx={{
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Цвет левого виджета"
|
||||
value={editCarrierData.left_color}
|
||||
className="flex-1 w-full"
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData.main_color,
|
||||
e.target.value,
|
||||
editCarrierData.right_color,
|
||||
editCarrierData.slogan,
|
||||
editCarrierData.logo
|
||||
)
|
||||
}
|
||||
type="color"
|
||||
sx={{
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Цвет правого виджета"
|
||||
value={editCarrierData.right_color}
|
||||
className="flex-1 w-full"
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData.main_color,
|
||||
editCarrierData.left_color,
|
||||
e.target.value,
|
||||
editCarrierData.slogan,
|
||||
editCarrierData.logo
|
||||
)
|
||||
}
|
||||
type="color"
|
||||
sx={{
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={editCarrierData.slogan}
|
||||
value={editCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData.main_color,
|
||||
editCarrierData.left_color,
|
||||
editCarrierData.right_color,
|
||||
e.target.value,
|
||||
editCarrierData.logo
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Логотип</InputLabel>
|
||||
<Select
|
||||
value={editCarrierData.logo || ""}
|
||||
label="Логотип"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData.full_name,
|
||||
editCarrierData.short_name,
|
||||
editCarrierData.city,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData.main_color,
|
||||
editCarrierData.left_color,
|
||||
editCarrierData.right_color,
|
||||
editCarrierData.slogan,
|
||||
e.target.value as string
|
||||
)
|
||||
}
|
||||
>
|
||||
{mediaStore.media
|
||||
.filter((media) => media.media_type === 3)
|
||||
.map((media) => (
|
||||
<MenuItem key={media.id} value={media.id}>
|
||||
{media.media_name || media.filename}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{editCarrierData.logo && (
|
||||
<div className="w-32 h-32">
|
||||
<MediaViewer
|
||||
media={{ id: editCarrierData.logo, media_type: 1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -289,19 +233,56 @@ export const CarrierEditPage = observer(() => {
|
||||
onClick={handleEdit}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!editCarrierData.full_name ||
|
||||
!editCarrierData.short_name ||
|
||||
!editCarrierData.city_id ||
|
||||
!editCarrierData.logo
|
||||
!editCarrierData[language].full_name ||
|
||||
!editCarrierData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editCarrierData[language].full_name}
|
||||
contextType="carrier"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteLogoModalOpen}
|
||||
onDelete={() => {
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setIsDeleteLogoModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteLogoModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { carrierStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, cityStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CarrierListPage = observer(() => {
|
||||
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
||||
const { getCities, cities } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getCarriers();
|
||||
}, []);
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
await getCarriers(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "full_name",
|
||||
headerName: "Полное имя",
|
||||
|
||||
width: 300,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "short_name",
|
||||
headerName: "Короткое имя",
|
||||
width: 200,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city",
|
||||
field: "city_id",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const city = cities[language]?.data.find(
|
||||
(city) => city.id == params.value
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{city && city.name ? (
|
||||
city.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -45,9 +96,9 @@ export const CarrierListPage = observer(() => {
|
||||
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
||||
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -62,25 +113,56 @@ export const CarrierListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = carriers.data?.map((carrier) => ({
|
||||
const rows = carriers[language].data?.map((carrier) => ({
|
||||
id: carrier.id,
|
||||
full_name: carrier.full_name,
|
||||
short_name: carrier.short_name,
|
||||
city: carrier.city,
|
||||
city_id: carrier.city_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Перевозчики</h1>
|
||||
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет перевозчиков"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +180,19 @@ export const CarrierListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteCarrier(id)));
|
||||
await getCarriers(language);
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { carrierStore, mediaStore } from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
export const CarrierPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getCarrier, carrier, setEditCarrierData } = carrierStore;
|
||||
const { oneMedia, getOneMedia } = mediaStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const carrierResponse = await getCarrier(Number(id));
|
||||
setEditCarrierData(
|
||||
carrierResponse?.full_name as string,
|
||||
carrierResponse?.short_name as string,
|
||||
carrierResponse?.city as string,
|
||||
carrierResponse?.city_id as number,
|
||||
carrierResponse?.main_color as string,
|
||||
carrierResponse?.left_color as string,
|
||||
carrierResponse?.right_color as string,
|
||||
carrierResponse?.slogan as string,
|
||||
carrierResponse?.logo as string
|
||||
);
|
||||
console.log(carrierResponse);
|
||||
await getOneMedia(carrierResponse?.logo as string);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
{carrier && (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Полное имя</h1>
|
||||
<p>{carrier[Number(id)]?.full_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Полное имя</h1>
|
||||
<p>{carrier[Number(id)]?.full_name}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Город</h1>
|
||||
<p>{carrier[Number(id)]?.city}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 ">
|
||||
<h1 className="text-lg font-bold">Основной цвет</h1>
|
||||
<div
|
||||
className="w-min"
|
||||
style={{
|
||||
backgroundColor: `${carrier[Number(id)]?.main_color}90`,
|
||||
}}
|
||||
>
|
||||
{carrier[Number(id)]?.main_color}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Цвет левого виджета</h1>
|
||||
<div
|
||||
className="w-min"
|
||||
style={{
|
||||
backgroundColor: `${carrier[Number(id)]?.left_color}90`,
|
||||
}}
|
||||
>
|
||||
{carrier[Number(id)]?.left_color}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Цвет правого виджета</h1>
|
||||
<div
|
||||
className="w-min"
|
||||
style={{
|
||||
backgroundColor: `${carrier[Number(id)]?.right_color}90`,
|
||||
}}
|
||||
>
|
||||
{carrier[Number(id)]?.right_color}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Краткое имя</h1>
|
||||
<p>{carrier[Number(id)]?.short_name}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Логотип</h1>
|
||||
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: oneMedia?.id as string,
|
||||
media_type: oneMedia?.media_type as number,
|
||||
filename: oneMedia?.filename,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./CarrierListPage";
|
||||
export * from "./CarrierPreviewPage";
|
||||
|
||||
export * from "./CarrierCreatePage";
|
||||
export * from "./CarrierEditPage";
|
||||
|
||||
@@ -6,17 +6,20 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, MediaViewer } from "@widgets";
|
||||
import { SelectMediaDialog } from "@shared";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -24,12 +27,20 @@ export const CityCreatePage = observer(() => {
|
||||
const { createCityData, setCreateCityData } = cityStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getCountries(language);
|
||||
await getCountries("ru");
|
||||
await getCountries("en");
|
||||
await getCountries("zh");
|
||||
await getMedia();
|
||||
})();
|
||||
}, [language]);
|
||||
@@ -55,7 +66,6 @@ export const CityCreatePage = observer(() => {
|
||||
}) => {
|
||||
setCreateCityData(
|
||||
createCityData[language].name,
|
||||
createCityData.country,
|
||||
createCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
@@ -80,6 +90,9 @@ export const CityCreatePage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{createCityData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название города"
|
||||
@@ -88,7 +101,6 @@ export const CityCreatePage = observer(() => {
|
||||
onChange={(e) =>
|
||||
setCreateCityData(
|
||||
e.target.value,
|
||||
createCityData.country,
|
||||
createCityData.country_code,
|
||||
createCityData.arms,
|
||||
language
|
||||
@@ -103,19 +115,15 @@ export const CityCreatePage = observer(() => {
|
||||
label="Страна"
|
||||
required
|
||||
onChange={(e) => {
|
||||
const selectedCountry = countryStore.countries[language]?.find(
|
||||
(country) => country.code === e.target.value
|
||||
);
|
||||
setCreateCityData(
|
||||
createCityData[language].name,
|
||||
selectedCountry?.name || "",
|
||||
e.target.value,
|
||||
createCityData.arms,
|
||||
language
|
||||
);
|
||||
}}
|
||||
>
|
||||
{countryStore.countries[language].map((country) => (
|
||||
{countryStore.countries["ru"]?.data?.map((country) => (
|
||||
<MenuItem key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</MenuItem>
|
||||
@@ -123,44 +131,36 @@ export const CityCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<label className="text-sm text-gray-600">Герб города</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectMediaOpen(true)}
|
||||
startIcon={<ImagePlus size={20} />}
|
||||
>
|
||||
Выбрать герб
|
||||
</Button>
|
||||
{selectedMedia && (
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedMedia.media_name || selectedMedia.filename}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedMedia && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: selectedMedia.id,
|
||||
media_type: selectedMedia.media_type,
|
||||
filename: selectedMedia.filename,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateCityData(
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -182,7 +182,24 @@ export const CityCreatePage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={3} // Тип медиа для иконок
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createCityData[language]?.name}
|
||||
contextType="city"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={
|
||||
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
|
||||
}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -21,19 +20,34 @@ import {
|
||||
CashedCities,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, MediaViewer } from "@widgets";
|
||||
import { SelectMediaDialog } from "@shared";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CityEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia, getOneMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -49,20 +63,23 @@ export const CityEditPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const data = await getCity(id as string, language);
|
||||
setEditCityData(
|
||||
data.name,
|
||||
data.country,
|
||||
data.country_code,
|
||||
data.arms,
|
||||
language
|
||||
);
|
||||
await getOneMedia(data.arms as string);
|
||||
await getCountries(language);
|
||||
await getCountries("ru");
|
||||
// Fetch data for all languages
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
const zhData = await getCity(id as string, "zh");
|
||||
|
||||
// Set data for each language
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
|
||||
await getMedia();
|
||||
}
|
||||
})();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
@@ -72,7 +89,6 @@ export const CityEditPage = observer(() => {
|
||||
}) => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country,
|
||||
editCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
@@ -97,6 +113,9 @@ export const CityEditPage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start ">
|
||||
<h1 className="text-3xl break-words">{editCityData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
@@ -105,7 +124,6 @@ export const CityEditPage = observer(() => {
|
||||
onChange={(e) =>
|
||||
setEditCityData(
|
||||
e.target.value,
|
||||
editCityData.country,
|
||||
editCityData.country_code,
|
||||
editCityData.arms,
|
||||
language
|
||||
@@ -120,19 +138,15 @@ export const CityEditPage = observer(() => {
|
||||
label="Страна"
|
||||
required
|
||||
onChange={(e) => {
|
||||
const selectedCountry = countryStore.countries[language]?.find(
|
||||
(country) => country.code === e.target.value
|
||||
);
|
||||
setEditCityData(
|
||||
editCityData[language as keyof CashedCities]?.name || "",
|
||||
selectedCountry?.name || "",
|
||||
editCityData[language].name,
|
||||
e.target.value,
|
||||
editCityData.arms,
|
||||
language
|
||||
);
|
||||
}}
|
||||
>
|
||||
{countryStore.countries[language].map((country) => (
|
||||
{countryStore.countries.ru.data.map((country) => (
|
||||
<MenuItem key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</MenuItem>
|
||||
@@ -140,44 +154,36 @@ export const CityEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<label className="text-sm text-gray-600">Герб города</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectMediaOpen(true)}
|
||||
startIcon={<ImagePlus size={20} />}
|
||||
>
|
||||
Выбрать герб
|
||||
</Button>
|
||||
{selectedMedia && (
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedMedia.media_name || selectedMedia.filename}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedMedia && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: selectedMedia.id,
|
||||
media_type: selectedMedia.media_type,
|
||||
filename: selectedMedia.filename,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -192,7 +198,7 @@ export const CityEditPage = observer(() => {
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -201,6 +207,29 @@ export const CityEditPage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editCityData[language].name}
|
||||
contextType="city"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={
|
||||
activeMenuType as
|
||||
| "thumbnail"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
| null
|
||||
}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,91 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, cityStore, CashedCities } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, cityStore, countryStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { toast } from "react-toastify";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CityListPage = observer(() => {
|
||||
const { cities, getCities, deleteCity } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getCities(language);
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await countryStore.getCountries("ru");
|
||||
await countryStore.getCountries("en");
|
||||
await countryStore.getCountries("zh");
|
||||
await getCities(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
let newRows = cities[language]?.data?.map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
country: city.country_code,
|
||||
}));
|
||||
|
||||
let newRows2: any[] = [];
|
||||
for (const city of newRows) {
|
||||
const name = countryStore.countries[language]?.data?.find(
|
||||
(country) => country.code === city.country
|
||||
)?.name;
|
||||
if (name) {
|
||||
newRows2.push(city);
|
||||
}
|
||||
}
|
||||
|
||||
setRows(newRows2 || []);
|
||||
}, [cities, countryStore.countries, language, isLoading]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "country",
|
||||
headerName: "Страна",
|
||||
width: 150,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
countryStore.countries[language]?.data?.find(
|
||||
(country) => country.code === params.value
|
||||
)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
@@ -34,18 +93,19 @@ export const CityListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
@@ -58,12 +118,6 @@ export const CityListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = cities[language].map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
country: city.country,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
@@ -73,11 +127,37 @@ export const CityListPage = observer(() => {
|
||||
<h1 className="text-2xl">Города</h1>
|
||||
<CreateButton label="Создать город" path="/city/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +165,8 @@ export const CityListPage = observer(() => {
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
deleteCity(rowId.toString(), language as keyof CashedCities);
|
||||
await deleteCity(rowId.toString());
|
||||
toast.success("Город успешно удален");
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
@@ -95,6 +176,20 @@ export const CityListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteCity(id.toString())));
|
||||
toast.success("Города успешно удалены");
|
||||
getCities(language);
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,18 +16,18 @@ export const CityPreviewPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const cityResponse = await getCity(id as string, language);
|
||||
setEditCityData(
|
||||
cityResponse.name,
|
||||
cityResponse.country,
|
||||
cityResponse.country_code,
|
||||
cityResponse.arms,
|
||||
language
|
||||
);
|
||||
await getOneMedia(cityResponse.arms as string);
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
const zhData = await getCity(id as string, "zh");
|
||||
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
}
|
||||
})();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
|
||||
115
src/pages/Country/CountryAddPage/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
FormControl,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
countryStore,
|
||||
RU_COUNTRIES,
|
||||
EN_COUNTRIES,
|
||||
ZH_COUNTRIES,
|
||||
} from "@shared";
|
||||
import { useState } from "react";
|
||||
|
||||
export const CountryAddPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||
|
||||
const handleCountryCodeChange = (code: string) => {
|
||||
const ruCountry = RU_COUNTRIES.find((c) => c.code === code);
|
||||
const enCountry = EN_COUNTRIES.find((c) => c.code === code);
|
||||
const zhCountry = ZH_COUNTRIES.find((c) => c.code === code);
|
||||
|
||||
if (ruCountry && enCountry && zhCountry) {
|
||||
setCountryData(code, ruCountry.name, "ru");
|
||||
setCountryData(code, enCountry.name, "en");
|
||||
setCountryData(code, zhCountry.name, "zh");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createCountry();
|
||||
toast.success("Страна успешно создана");
|
||||
navigate("/country");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании страны");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<FormControl fullWidth>
|
||||
<Autocomplete
|
||||
value={
|
||||
RU_COUNTRIES.find((c) => c.code === createCountryData.code) ||
|
||||
null
|
||||
}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue) {
|
||||
handleCountryCodeChange(newValue.code);
|
||||
}
|
||||
}}
|
||||
options={RU_COUNTRIES}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.name}`}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Страна"
|
||||
required
|
||||
inputProps={{
|
||||
...params.inputProps,
|
||||
maxLength: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchValue = inputValue.toUpperCase();
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.code.includes(searchValue) ||
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !createCountryData.code}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
@@ -41,6 +41,9 @@ export const CountryCreatePage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{createCountryData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код страны"
|
||||
|
||||
@@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => {
|
||||
const { editCountryData, editCountry, getCountry, setEditCountryData } =
|
||||
countryStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -31,11 +36,18 @@ export const CountryEditPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const data = await getCountry(id as string, language);
|
||||
setEditCountryData(data.name, language);
|
||||
// Fetch data for all languages
|
||||
const ruData = await getCountry(id as string, "ru");
|
||||
const enData = await getCountry(id as string, "en");
|
||||
const zhData = await getCountry(id as string, "zh");
|
||||
|
||||
// Set data for each language
|
||||
setEditCountryData(ruData.name, "ru");
|
||||
setEditCountryData(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
}
|
||||
})();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -51,6 +63,9 @@ export const CountryEditPage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words t">{editCountryData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код страны"
|
||||
@@ -78,7 +93,7 @@ export const CountryEditPage = observer(() => {
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Trash2, Minus } from "lucide-react";
|
||||
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CountryListPage = observer(() => {
|
||||
const { countries, getCountries } = countryStore;
|
||||
const navigate = useNavigate();
|
||||
const { countries, getCountries, deleteCountry } = countryStore;
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getCountries(language);
|
||||
const fetchCountries = async () => {
|
||||
setIsLoading(true);
|
||||
await getCountries(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchCountries();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -22,6 +32,17 @@ export const CountryListPage = observer(() => {
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center ">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
@@ -29,19 +50,21 @@ export const CountryListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button
|
||||
{/* <button
|
||||
onClick={() => navigate(`/country/${params.row.code}/edit`)}
|
||||
>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/country/${params.row.code}`)}>
|
||||
</button> */}
|
||||
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.code);
|
||||
}}
|
||||
@@ -54,7 +77,7 @@ export const CountryListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = countries[language]?.map((country) => ({
|
||||
const rows = countries[language]?.data.map((country) => ({
|
||||
id: country.code,
|
||||
code: country.code,
|
||||
name: country.name,
|
||||
@@ -67,25 +90,66 @@ export const CountryListPage = observer(() => {
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Страны</h1>
|
||||
<CreateButton label="Создать страну" path="/country/create" />
|
||||
<CreateButton label="Добавить страну" path="/country/add" />
|
||||
</div>
|
||||
<DataGrid rows={rows} columns={columns} hideFooter />
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows || []}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await countryStore.deleteCountry(rowId, language);
|
||||
getCountries(language); // Refresh the list after deletion
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
if (!rowId) return;
|
||||
await deleteCountry(rowId);
|
||||
setRowId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteCountry(id.toString())));
|
||||
getCountries(language);
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -15,11 +15,16 @@ export const CountryPreviewPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const data = await getCountry(id as string, language);
|
||||
setEditCountryData(data.name, language);
|
||||
const ruData = await getCountry(id as string, "ru");
|
||||
const enData = await getCountry(id as string, "en");
|
||||
const zhData = await getCountry(id as string, "zh");
|
||||
|
||||
setEditCountryData(ruData.name, "ru");
|
||||
setEditCountryData(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
}
|
||||
})();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -55,7 +60,7 @@ export const CountryPreviewPage = observer(() => {
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Название</h1>
|
||||
<p>{country[id!]?.[language]?.name}</p>
|
||||
<p>{country[id!]?.ru?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./CountryListPage";
|
||||
export * from "./CountryPreviewPage";
|
||||
export * from "./CountryCreatePage";
|
||||
export * from "./CountryEditPage";
|
||||
export * from "./CountryAddPage";
|
||||
|
||||
@@ -3,12 +3,7 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
|
||||
import { LeftWidgetTab } from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import {
|
||||
articlesStore,
|
||||
cityStore,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import { articlesStore, cityStore, editSightStore } from "@shared";
|
||||
import { useBlocker, useParams } from "react-router-dom";
|
||||
|
||||
function a11yProps(index: number) {
|
||||
@@ -22,9 +17,9 @@ export const EditSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
|
||||
const { getArticles } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const { id } = useParams();
|
||||
const { getRuCities } = cityStore;
|
||||
const { getCities } = cityStore;
|
||||
|
||||
let blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
@@ -38,13 +33,17 @@ export const EditSightPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getSightInfo(+id, language);
|
||||
await getArticles(language);
|
||||
await getRuCities();
|
||||
await getCities("ru");
|
||||
await getSightInfo(+id, "ru");
|
||||
await getSightInfo(+id, "en");
|
||||
await getSightInfo(+id, "zh");
|
||||
await getArticles("ru");
|
||||
await getArticles("en");
|
||||
await getArticles("zh");
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { authStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import { authStore, userStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -15,9 +18,21 @@ export const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = authStore;
|
||||
const { getUsers } = userStore;
|
||||
useEffect(() => {
|
||||
// Load saved credentials if they exist
|
||||
const savedEmail = localStorage.getItem("rememberedEmail");
|
||||
const savedPassword = localStorage.getItem("rememberedPassword");
|
||||
if (savedEmail && savedPassword) {
|
||||
setEmail(savedEmail);
|
||||
setPassword(savedPassword);
|
||||
setRememberMe(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,7 +41,23 @@ export const LoginPage = () => {
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/sight");
|
||||
|
||||
// Save or clear credentials based on remember me checkbox
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("rememberedEmail", email);
|
||||
localStorage.setItem("rememberedPassword", password);
|
||||
} else {
|
||||
localStorage.removeItem("rememberedEmail");
|
||||
localStorage.removeItem("rememberedPassword");
|
||||
}
|
||||
|
||||
navigate("/map");
|
||||
try {
|
||||
await getUsers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
toast.success("Вход в систему выполнен успешно");
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -47,73 +78,102 @@ export const LoginPage = () => {
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
gap: 3,
|
||||
p: 3,
|
||||
backgroundImage: "url('/login-bg.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Вход в систему
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
p: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "white",
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
className="text-center pb-[50px]"
|
||||
>
|
||||
Вход в систему
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
label="Запомнить пароль"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,20 +54,22 @@ class MapStore {
|
||||
sights: ApiSight[] = [];
|
||||
|
||||
getRoutes = async () => {
|
||||
const routes = await languageInstance("ru").get("/route");
|
||||
const routedIds = routes.data.map((route: any) => route.id);
|
||||
const mappedRoutes: ApiRoute[] = [];
|
||||
for (const routeId of routedIds) {
|
||||
const responseSoloRoute = await languageInstance("ru").get(
|
||||
`/route/${routeId}`
|
||||
);
|
||||
const route = responseSoloRoute.data;
|
||||
mappedRoutes.push({
|
||||
id: route.id,
|
||||
route_number: route.route_number,
|
||||
path: route.path,
|
||||
const response = await languageInstance("ru").get("/route");
|
||||
|
||||
const routesIds = response.data.map((route: any) => route.id);
|
||||
for (const id of routesIds) {
|
||||
const route = await languageInstance("ru").get(`/route/${id}`);
|
||||
this.routes.push({
|
||||
id: route.data.id,
|
||||
route_number: route.data.route_number,
|
||||
path: route.data.path,
|
||||
});
|
||||
}
|
||||
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
|
||||
id: route.id,
|
||||
route_number: route.route_number,
|
||||
path: route.path,
|
||||
}));
|
||||
this.routes = mappedRoutes.sort((a, b) =>
|
||||
a.route_number.localeCompare(b.route_number)
|
||||
);
|
||||
@@ -114,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;
|
||||
@@ -209,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))
|
||||
);
|
||||
@@ -237,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,20 +1,30 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Trash2 } from "lucide-react";
|
||||
import { Eye, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { DeleteModal } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const MediaListPage = observer(() => {
|
||||
const { media, getMedia, deleteMedia } = mediaStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getMedia();
|
||||
const fetchMedia = async () => {
|
||||
setIsLoading(true);
|
||||
await getMedia();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchMedia();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -22,6 +32,17 @@ export const MediaListPage = observer(() => {
|
||||
field: "media_name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "media_type",
|
||||
@@ -30,13 +51,15 @@ export const MediaListPage = observer(() => {
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<p>
|
||||
{
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
MEDIA_TYPE_LABELS[
|
||||
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
|
||||
]
|
||||
}
|
||||
</p>
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -46,6 +69,7 @@ export const MediaListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -76,15 +100,37 @@ export const MediaListPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Медиа</h1>
|
||||
<CreateButton label="Создать медиа" path="/media/create" />
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as string[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +149,19 @@ export const MediaListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteMedia(id)));
|
||||
getMedia();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,30 +15,32 @@ export const MediaPreviewPage = observer(() => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<MediaViewer className="w-full h-full" media={oneMedia!} />
|
||||
</div>
|
||||
|
||||
{oneMedia && (
|
||||
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||
<p className="text-white text-center">
|
||||
Чтобы скачать файл, нажмите на кнопку ниже
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="flex justify-center items-center max-w-[60%]">
|
||||
<MediaViewer media={oneMedia!} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oneMedia && (
|
||||
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||
<p className="text-white text-center">
|
||||
Чтобы скачать файл, нажмите на кнопку ниже
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
608
src/pages/Route/LinekedStations.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
FormControl,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
IconButton,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import {
|
||||
authInstance,
|
||||
languageStore,
|
||||
routeStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||||
|
||||
// Helper function to insert an item at a specific position (1-based index)
|
||||
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||
const index = pos - 1;
|
||||
const result = [...arr];
|
||||
if (index >= result.length) {
|
||||
result.push(value);
|
||||
} else {
|
||||
result.splice(index, 0, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper function to reorder items after drag and drop
|
||||
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedItemsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
dragAllowed?: boolean;
|
||||
onUpdate?: () => void;
|
||||
dontRecurse?: boolean;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
routeDirection?: boolean;
|
||||
};
|
||||
|
||||
export const LinkedItems = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedItemsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные станции
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedItemsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedItemsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
dragAllowed = false,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
routeDirection,
|
||||
}: LinkedItemsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [position, setPosition] = useState<number>(1);
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "route";
|
||||
const childResource = "station";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
// Если направление маршрута не указано, показываем все станции
|
||||
if (routeDirection === undefined) return true;
|
||||
// Фильтруем станции по направлению маршрута
|
||||
return item.direction === routeDirection;
|
||||
})
|
||||
.filter((item) => {
|
||||
// Фильтруем по городу из навбара
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Фильтрация по поиску для массового режима
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(linkedItems.length + 1);
|
||||
}, [linkedItems.length]);
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const reorderedItems = reorder(
|
||||
linkedItems,
|
||||
result.source.index,
|
||||
result.destination.index
|
||||
);
|
||||
|
||||
setLinkedItems(reorderedItems);
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
stations: reorderedItems.map((item) => ({ id: item.id })),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating station order:", error);
|
||||
setError("Failed to update station order");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked items:", error);
|
||||
setError("Failed to load linked stations");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all items:", error);
|
||||
setError("Failed to load available stations");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
stations: insertAtPosition(
|
||||
linkedItems.map((item) => ({ id: item.id })),
|
||||
position,
|
||||
{ id: selectedItemId }
|
||||
),
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
const updatedList = insertAtPosition(
|
||||
[...linkedItems],
|
||||
position,
|
||||
newItem
|
||||
);
|
||||
setLinkedItems(updatedList);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking item:", error);
|
||||
setError("Failed to link station");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting item:", error);
|
||||
setError("Failed to delete station");
|
||||
});
|
||||
};
|
||||
|
||||
const handleStationClick = (item: T) => {
|
||||
routeStore.setSelectedStationId(item.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (itemId: number) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
const handleBulkLink = () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setError(null);
|
||||
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
|
||||
const requestData = {
|
||||
stations: [
|
||||
...linkedItems.map((item) => ({ id: item.id })),
|
||||
...selectedStations,
|
||||
],
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItems = allItems.filter((item) => selectedItems.has(item.id));
|
||||
setLinkedItems([...linkedItems, ...newItems]);
|
||||
setSelectedItems(new Set());
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking items:", error);
|
||||
setError("Failed to link stations");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell width="40px"></TableCell>
|
||||
)}
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>
|
||||
{field.label}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<Droppable
|
||||
droppableId="droppable-stations"
|
||||
isDropDisabled={type !== "edit" || !dragAllowed}
|
||||
>
|
||||
{(provided) => (
|
||||
<TableBody
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{linkedItems.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={"station-" + String(item.id)}
|
||||
index={index}
|
||||
isDragDisabled={type !== "edit" || !dragAllowed}
|
||||
>
|
||||
{(provided) => (
|
||||
<TableRow
|
||||
sx={{ cursor: "pointer" }}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
hover
|
||||
onClick={() => handleStationClick(item)}
|
||||
>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell {...provided.dragHandleProps}>
|
||||
<IconButton size="small">
|
||||
<DragIndicatorIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</TableBody>
|
||||
)}
|
||||
</Droppable>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Станции не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||
{routeDirection !== undefined && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Показываются только остановки для{" "}
|
||||
{routeDirection ? "прямого" : "обратного"} направления
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||
>
|
||||
<Tab label="По одной" />
|
||||
<Tab label="Массово" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{activeTab === 0 && (
|
||||
<Stack gap={2}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find(
|
||||
(item) => item.id === selectedItemId
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<p>{String(option.name)}</p>
|
||||
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
|
||||
{String(option.description)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Позиция добавляемой остановки"
|
||||
value={position}
|
||||
onChange={(e) => {
|
||||
const newValue = Math.max(1, Number(e.target.value));
|
||||
setPosition(
|
||||
newValue > linkedItems.length + 1
|
||||
? linkedItems.length + 1
|
||||
: newValue
|
||||
);
|
||||
}}
|
||||
InputProps={{
|
||||
inputProps: { min: 1, max: linkedItems.length + 1 },
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<Stack gap={2}>
|
||||
{/* Поле поиска */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Поиск остановок"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Введите название остановки..."
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
{/* Список доступных остановок с чекбоксами */}
|
||||
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||
<Stack gap={1}>
|
||||
{filteredAvailableItems.map((item) => (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => handleCheckboxChange(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{filteredAvailableItems.length === 0 && (
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
textAlign="center"
|
||||
py={1}
|
||||
>
|
||||
{searchQuery.trim()
|
||||
? "Остановки не найдены"
|
||||
: "Нет доступных остановок"}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleBulkLink}
|
||||
disabled={selectedItems.size === 0}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить выбранные ({selectedItems.size})
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedItemsContents = observer(
|
||||
LinkedItemsContentsInner
|
||||
) as typeof LinkedItemsContentsInner;
|
||||
@@ -8,16 +8,26 @@ import {
|
||||
InputLabel,
|
||||
Typography,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||
import {
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -32,12 +42,89 @@ export const RouteCreatePage = observer(() => {
|
||||
const [turn, setTurn] = useState("");
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers();
|
||||
carrierStore.getCarriers(language);
|
||||
articlesStore.getArticleList();
|
||||
}, []);
|
||||
}, [language]);
|
||||
|
||||
// Фильтруем перевозчиков только из выбранного города
|
||||
const filteredCarriers = useMemo(() => {
|
||||
const carriers =
|
||||
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||||
.data || [];
|
||||
|
||||
if (!selectedCityStore.selectedCityId) {
|
||||
return carriers;
|
||||
}
|
||||
|
||||
return carriers.filter(
|
||||
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
|
||||
);
|
||||
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
const coordinates = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
return "Введите хотя бы одну пару координат";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every(
|
||||
(point) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every((point) =>
|
||||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return "Неверный формат координат";
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
setGovernorAppeal(articleId.toString());
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setVideoPreview(media.id);
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
setIsVideoPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateRoute = async () => {
|
||||
try {
|
||||
@@ -51,22 +138,31 @@ export const RouteCreatePage = observer(() => {
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
const route_direction = direction === "forward";
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Координаты маршрута как массив массивов чисел
|
||||
const path = routeCoords
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
line
|
||||
.split(" ")
|
||||
.map((coord) => Number(coord.trim()))
|
||||
.filter((n) => !isNaN(n))
|
||||
)
|
||||
.filter((arr) => arr.length === 2);
|
||||
.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
// Собираем объект маршрута
|
||||
const newRoute: Partial<Route> = {
|
||||
carrier:
|
||||
carrierStore.carriers.data.find((c: any) => c.id === carrier_id)
|
||||
?.full_name || "",
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
|
||||
carrier_id,
|
||||
route_number: routeNumber,
|
||||
route_sys_number: govRouteNumber,
|
||||
@@ -78,6 +174,8 @@ export const RouteCreatePage = observer(() => {
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
path,
|
||||
video_preview:
|
||||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||
};
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
@@ -91,137 +189,300 @@ export const RouteCreatePage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === Number(governorAppeal)
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Маршруты / Создать
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
Создать маршрут
|
||||
</Typography>
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={carrier}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) => setCarrier(e.target.value as string)}
|
||||
disabled={carrierStore.carriers.data.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{carrierStore.carriers.data.map(
|
||||
(c: (typeof carrierStore.carriers.data)[number]) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.full_name}
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={carrier}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) => setCarrier(e.target.value as string)}
|
||||
disabled={filteredCarriers.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{filteredCarriers.map((carrier: any) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута"
|
||||
required
|
||||
value={routeNumber}
|
||||
onChange={(e) => setRouteNumber(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={routeCoords}
|
||||
onChange={(e) => setRouteCoords(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
required
|
||||
value={govRouteNumber}
|
||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Обращение губернатора</InputLabel>
|
||||
<Select
|
||||
value={governorAppeal}
|
||||
label="Обращение губернатора"
|
||||
onChange={(e) => setGovernorAppeal(e.target.value as string)}
|
||||
disabled={articlesStore.articleList.ru.data.length === 0}
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута"
|
||||
required
|
||||
value={routeNumber}
|
||||
onChange={(e) => setRouteNumber(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
value={routeCoords}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setRouteCoords(newValue);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const lines = routeCoords.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = routeCoords + "\n";
|
||||
setRouteCoords(newValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
error={validateCoordinates(routeCoords) !== true}
|
||||
helperText={
|
||||
typeof validateCoordinates(routeCoords) === "string"
|
||||
? validateCoordinates(routeCoords)
|
||||
: "Формат: широта долгота"
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "2px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
required
|
||||
value={govRouteNumber}
|
||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
videoPreview && videoPreview !== "" ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{videoPreview && videoPreview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVideoPreview("");
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-lg font-bold">
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={direction}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
>
|
||||
<MenuItem value="forward">Прямой</MenuItem>
|
||||
<MenuItem value="backward">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
value={scaleMin}
|
||||
onChange={(e) => setScaleMin(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
value={scaleMax}
|
||||
onChange={(e) => setScaleMax(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
value={turn}
|
||||
onChange={(e) => setTurn(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. широта"
|
||||
value={centerLat}
|
||||
onChange={(e) => setCenterLat(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. долгота"
|
||||
value={centerLng}
|
||||
onChange={(e) => setCenterLng(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreateRoute}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{articlesStore.articleList.ru.data.map(
|
||||
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{a.heading}
|
||||
</MenuItem>
|
||||
)
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={direction}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
>
|
||||
<MenuItem value="forward">Прямой</MenuItem>
|
||||
<MenuItem value="backward">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
value={scaleMin}
|
||||
onChange={(e) => setScaleMin(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
value={scaleMax}
|
||||
onChange={(e) => setScaleMax(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
value={turn}
|
||||
onChange={(e) => setTurn(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. широта"
|
||||
value={centerLat}
|
||||
onChange={(e) => setCenterLat(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. долгота"
|
||||
value={centerLng}
|
||||
onChange={(e) => setCenterLng(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreateRoute}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: videoPreview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,33 +8,68 @@ import {
|
||||
InputLabel,
|
||||
Typography,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { routeStore } from "../../../shared/store/RouteStore";
|
||||
import {
|
||||
routeStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
import { LinkedItems } from "../LinekedStations";
|
||||
|
||||
export const RouteEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { editRouteData } = routeStore;
|
||||
const { editRouteData, copyRouteAction } = routeStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
carrierStore.getCarriers();
|
||||
languageStore.setLanguage("ru");
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
carrierStore.getCarriers(language);
|
||||
stationsStore.getStations();
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
fetchData();
|
||||
}, [id]);
|
||||
}, [id, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editRouteData.path && editRouteData.path.length > 0) {
|
||||
const formattedPath = editRouteData.path
|
||||
.map((coords) => coords.join(" "))
|
||||
.join("\n");
|
||||
setCoordinates(formattedPath);
|
||||
}
|
||||
}, [editRouteData.path]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -43,183 +78,460 @@ export const RouteEditPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
const coordinates = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
return "Введите хотя бы одну пару координат";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every(
|
||||
(point) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every((point) =>
|
||||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return "Неверный формат координат";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copyRouteAction(Number(id));
|
||||
toast.success("Маршрут успешно скопирован");
|
||||
};
|
||||
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
routeStore.setEditRouteData({
|
||||
governor_appeal: articleId,
|
||||
});
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||
setIsVideoPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === editRouteData.governor_appeal
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Маршруты / Редактировать
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
Редактировать маршрут
|
||||
</Typography>
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.carrier_id}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
carrier_id: Number(e.target.value),
|
||||
carrier:
|
||||
carrierStore.carriers.data.find(
|
||||
(c) => c.id === Number(e.target.value)
|
||||
)?.full_name || "",
|
||||
})
|
||||
}
|
||||
disabled={carrierStore.carriers.data.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{carrierStore.carriers.data.map(
|
||||
(c: (typeof carrierStore.carriers.data)[number]) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.full_name}
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.carrier_id}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
carrier_id: Number(e.target.value),
|
||||
carrier:
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.find((c) => c.id === Number(e.target.value))
|
||||
?.full_name || "",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.length === 0
|
||||
}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.map((carrier) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута"
|
||||
required
|
||||
value={editRouteData.route_number || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_number: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
path: e.target.value
|
||||
.split("\n")
|
||||
.map((line) => line.split(" ").map(Number)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
required
|
||||
value={editRouteData.route_sys_number || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_sys_number: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Обращение губернатора</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.governor_appeal || ""}
|
||||
label="Обращение губернатора"
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута"
|
||||
required
|
||||
value={editRouteData.route_number || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
governor_appeal: Number(e.target.value),
|
||||
route_number: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={articlesStore.articleList.ru.data.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{articlesStore.articleList.ru.data.map(
|
||||
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{a.heading}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.route_direction ? "forward" : "backward"}
|
||||
label="Прямой/обратный маршрут"
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const validationResult = validateCoordinates(newValue);
|
||||
if (validationResult === true) {
|
||||
const lines = newValue.trim().split("\n");
|
||||
const path = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
routeStore.setEditRouteData({ path });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const lines = coordinates.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = coordinates + "\n";
|
||||
setCoordinates(newValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
error={validateCoordinates(coordinates) !== true}
|
||||
helperText={
|
||||
typeof validateCoordinates(coordinates) === "string"
|
||||
? validateCoordinates(coordinates)
|
||||
: "Формат: широта долгота"
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "2px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
required
|
||||
value={editRouteData.route_sys_number || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_direction: e.target.value === "forward",
|
||||
route_sys_number: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{editRouteData.video_preview &&
|
||||
editRouteData.video_preview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className="text-lg font-bold"
|
||||
>
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.route_direction ? "forward" : "backward"}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_direction: e.target.value === "forward",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="forward">Прямой</MenuItem>
|
||||
<MenuItem value="backward">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
value={editRouteData.scale_min ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_min:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
value={editRouteData.scale_max ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_max:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
value={editRouteData.rotate ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
rotate:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. широта"
|
||||
value={editRouteData.center_latitude ?? ""}
|
||||
type="text"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
center_latitude: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. долгота"
|
||||
value={editRouteData.center_longitude ?? ""}
|
||||
type="text"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
center_longitude: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinkedItems
|
||||
parentId={id || ""}
|
||||
type="edit"
|
||||
dragAllowed={true}
|
||||
fields={[
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Описание", data: "description" },
|
||||
]}
|
||||
onUpdate={() => {
|
||||
routeStore.getRoute(Number(id));
|
||||
}}
|
||||
routeDirection={editRouteData.route_direction}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-between">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Copy size={20} />}
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<MenuItem value="forward">Прямой</MenuItem>
|
||||
<MenuItem value="backward">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
value={editRouteData.scale_min || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_min: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
value={editRouteData.scale_max || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_max: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
value={editRouteData.rotate || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
rotate: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. широта"
|
||||
value={editRouteData.center_latitude || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
center_latitude: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. долгота"
|
||||
value={editRouteData.center_longitude || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
center_longitude: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
Скопировать
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,32 +1,70 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, routeStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, routeStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Map, Pencil, Trash2 } from "lucide-react";
|
||||
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const RouteListPage = observer(() => {
|
||||
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getRoutes();
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getCarriers("ru");
|
||||
await getCarriers("en");
|
||||
await getCarriers("zh");
|
||||
await getRoutes();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "carrier",
|
||||
field: "carrier_id",
|
||||
headerName: "Перевозчик",
|
||||
width: 250,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
carriers[language].data.find(
|
||||
(carrier) => carrier.id == params.value
|
||||
)?.short_name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_number",
|
||||
headerName: "Номер маршрута",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_direction",
|
||||
@@ -52,6 +90,7 @@ export const RouteListPage = observer(() => {
|
||||
width: 250,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
@@ -80,23 +119,51 @@ export const RouteListPage = observer(() => {
|
||||
|
||||
const rows = routes.data.map((route) => ({
|
||||
id: route.id,
|
||||
carrier: route.carrier,
|
||||
carrier_id: route.carrier_id,
|
||||
route_number: route.route_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Маршруты</h1>
|
||||
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +181,19 @@ export const RouteListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteRoute(id)));
|
||||
getRoutes();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const UP_SCALE = 30000;
|
||||
export const PATH_WIDTH = 15;
|
||||
export const STATION_RADIUS = 20;
|
||||
export const STATION_OUTLINE_WIDTH = 10;
|
||||
export const SIGHT_SIZE = 60;
|
||||
export const UP_SCALE = 10000;
|
||||
export const PATH_WIDTH = 5;
|
||||
export const STATION_RADIUS = 8;
|
||||
export const STATION_OUTLINE_WIDTH = 4;
|
||||
export const SIGHT_SIZE = 40;
|
||||
export const SCALE_FACTOR = 50;
|
||||
|
||||
export const BACKGROUND_COLOR = 0x111111;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function InfiniteCanvas({
|
||||
setScreenCenter,
|
||||
screenCenter,
|
||||
} = useTransform();
|
||||
const { routeData, originalRouteData } = useMapData();
|
||||
const { routeData, originalRouteData, setSelectedSight } = useMapData();
|
||||
|
||||
const applicationRef = useApplication();
|
||||
|
||||
@@ -45,6 +45,7 @@ export function InfiniteCanvas({
|
||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [startRotation, setStartRotation] = useState(0);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
|
||||
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
|
||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||
@@ -53,19 +54,20 @@ export function InfiniteCanvas({
|
||||
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = applicationRef?.app.canvas;
|
||||
if (!canvas) return;
|
||||
if (!applicationRef?.app?.canvas) return;
|
||||
|
||||
const canvas = applicationRef.app.canvas;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const canvasLeft = canvasRect.left;
|
||||
const canvasTop = canvasRect.top;
|
||||
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||
const centerY = window.innerHeight / 2 - canvasTop;
|
||||
setScreenCenter({ x: centerX, y: centerY });
|
||||
}, [applicationRef?.app.canvas, setScreenCenter]);
|
||||
}, [applicationRef?.app, setScreenCenter]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
@@ -93,7 +95,18 @@ export function InfiniteCanvas({
|
||||
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
||||
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
if (!isPointerDown) return;
|
||||
|
||||
// Проверяем, началось ли перетаскивание
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
@@ -136,6 +149,12 @@ export function InfiniteCanvas({
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
// Если не было перетаскивания, то это простой клик - закрываем виджет
|
||||
if (!isDragging) {
|
||||
setSelectedSight(undefined);
|
||||
}
|
||||
|
||||
setIsPointerDown(false);
|
||||
setIsDragging(false);
|
||||
// Сбрасываем флаг взаимодействия через небольшую задержку
|
||||
// чтобы избежать немедленного срабатывания useEffect
|
||||
@@ -185,7 +204,6 @@ export function InfiniteCanvas({
|
||||
|
||||
useEffect(() => {
|
||||
applicationRef?.app.render();
|
||||
console.log(position, scale, rotation);
|
||||
}, [position, scale, rotation]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { Stack, Typography, Button } from "@mui/material";
|
||||
|
||||
import { useNavigate, useNavigationType } from "react-router";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
export function LeftSidebar() {
|
||||
export const LeftSidebar = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||
const { routeData } = useMapData();
|
||||
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
async function fetchCarrierThumbnail() {
|
||||
if (routeData?.carrier_id) {
|
||||
const { city_id, logo } = (
|
||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||
).data;
|
||||
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||
setCarrierThumbnail(arms);
|
||||
setCarrierLogo(logo);
|
||||
}
|
||||
}
|
||||
fetchCarrierThumbnail();
|
||||
}, [routeData?.carrier_id]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (navigationType === "PUSH") {
|
||||
@@ -27,6 +47,7 @@ export function LeftSidebar() {
|
||||
color: "#fff",
|
||||
backgroundColor: "#222",
|
||||
borderRadius: 10,
|
||||
height: 40,
|
||||
width: "100%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
@@ -41,10 +62,30 @@ export function LeftSidebar() {
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
>
|
||||
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
|
||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства Санкт-Петербурга
|
||||
</Typography>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{carrierThumbnail && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства
|
||||
</Typography>{" "}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
@@ -65,15 +106,20 @@ export function LeftSidebar() {
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
maxHeight={150}
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
>
|
||||
<img
|
||||
src={"/GET.png"}
|
||||
alt="logo"
|
||||
width="80%"
|
||||
style={{ margin: "0 auto" }}
|
||||
/>
|
||||
{carrierLogo && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail_logo",
|
||||
}}
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
@@ -86,4 +132,4 @@ export function LeftSidebar() {
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,10 +29,13 @@ const MapDataContext = createContext<{
|
||||
isRouteLoading: boolean;
|
||||
isStationLoading: boolean;
|
||||
isSightLoading: boolean;
|
||||
selectedSight?: SightData;
|
||||
setSelectedSight: (sight?: SightData) => void;
|
||||
setScaleRange: (min: number, max: number) => void;
|
||||
setMapRotation: (rotation: number) => void;
|
||||
setMapCenter: (x: number, y: number) => void;
|
||||
setStationOffset: (stationId: number, x: number, y: number) => void;
|
||||
setStationAlign: (stationId: number, align: number) => void;
|
||||
setSightCoordinates: (
|
||||
sightId: number,
|
||||
latitude: number,
|
||||
@@ -50,10 +53,13 @@ const MapDataContext = createContext<{
|
||||
isRouteLoading: true,
|
||||
isStationLoading: true,
|
||||
isSightLoading: true,
|
||||
selectedSight: undefined,
|
||||
setSelectedSight: () => {},
|
||||
setScaleRange: () => {},
|
||||
setMapRotation: () => {},
|
||||
setMapCenter: () => {},
|
||||
setStationOffset: () => {},
|
||||
setStationAlign: () => {},
|
||||
setSightCoordinates: () => {},
|
||||
saveChanges: () => {},
|
||||
});
|
||||
@@ -87,6 +93,7 @@ export const MapDataProvider = observer(
|
||||
const [isRouteLoading, setIsRouteLoading] = useState(true);
|
||||
const [isStationLoading, setIsStationLoading] = useState(true);
|
||||
const [isSightLoading, setIsSightLoading] = useState(true);
|
||||
const [selectedSight, setSelectedSight] = useState<SightData>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -106,17 +113,18 @@ export const MapDataProvider = observer(
|
||||
languageInstance("ru").get(`/route/${routeId}/station`),
|
||||
languageInstance("en").get(`/route/${routeId}/station`),
|
||||
languageInstance("zh").get(`/route/${routeId}/station`),
|
||||
authInstance.get(`/route/${routeId}/sight`),
|
||||
languageInstance("ru").get(`/route/${routeId}/sight`),
|
||||
]);
|
||||
|
||||
setOriginalRouteData(routeResponse.data as RouteData);
|
||||
const routeData = routeResponse.data as RouteData;
|
||||
setOriginalRouteData(routeData);
|
||||
setOriginalStationData(ruStationResponse.data as StationData[]);
|
||||
setStationData({
|
||||
ru: ruStationResponse.data as StationData[],
|
||||
en: enStationResponse.data as StationData[],
|
||||
zh: zhStationResponse.data as StationData[],
|
||||
});
|
||||
setOriginalSightData(sightResponse as unknown as SightData[]);
|
||||
setOriginalSightData(sightResponse.data as SightData[]);
|
||||
|
||||
setIsRouteLoading(false);
|
||||
setIsStationLoading(false);
|
||||
@@ -176,43 +184,136 @@ export const MapDataProvider = observer(
|
||||
}
|
||||
|
||||
async function saveSightChanges() {
|
||||
console.log("sightChanges", sightChanges);
|
||||
for (const sight of sightChanges) {
|
||||
await authInstance.patch(`/route/${routeId}/sight`, sight);
|
||||
}
|
||||
}
|
||||
|
||||
function setStationOffset(stationId: number, x: number, y: number) {
|
||||
setStationChanges((prev) => {
|
||||
let found = prev.find((station) => station.station_id === stationId);
|
||||
if (found) {
|
||||
found.offset_x = x;
|
||||
found.offset_y = y;
|
||||
const currentStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
if (
|
||||
currentStation &&
|
||||
Math.abs(currentStation.offset_x - x) < 0.01 &&
|
||||
Math.abs(currentStation.offset_y - y) < 0.01
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return prev.map((station) => {
|
||||
if (station.station_id === stationId) {
|
||||
return found;
|
||||
setStationChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(station) => station.station_id === stationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const newChanges = [...prev];
|
||||
newChanges[existingIndex] = {
|
||||
...newChanges[existingIndex],
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
};
|
||||
return newChanges;
|
||||
} else {
|
||||
const originalStation = originalStationData?.find(
|
||||
(s) => s.id === stationId
|
||||
);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
align: originalStation?.align ?? 1,
|
||||
transfers: originalStation?.transfers ?? {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
setStationData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((lang) => {
|
||||
updated[lang] = updated[lang].map((station) => {
|
||||
if (station.id === stationId) {
|
||||
return { ...station, offset_x: x, offset_y: y };
|
||||
}
|
||||
return station;
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function setStationAlign(stationId: number, align: number) {
|
||||
const currentStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
if (currentStation && currentStation.align === align) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStationChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(station) => station.station_id === stationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const newChanges = [...prev];
|
||||
newChanges[existingIndex] = {
|
||||
...newChanges[existingIndex],
|
||||
align: align,
|
||||
};
|
||||
return newChanges;
|
||||
} else {
|
||||
const foundStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
const originalStation = originalStationData?.find(
|
||||
(s) => s.id === stationId
|
||||
);
|
||||
if (foundStation) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
transfers: foundStation.transfers,
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
align: align,
|
||||
offset_x: originalStation?.offset_x ?? 0,
|
||||
offset_y: originalStation?.offset_y ?? 0,
|
||||
transfers: originalStation?.transfers ?? {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
setStationData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((lang) => {
|
||||
updated[lang] = updated[lang].map((station) => {
|
||||
if (station.id === stationId) {
|
||||
return { ...station, align: align };
|
||||
}
|
||||
return station;
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function setSightCoordinates(
|
||||
@@ -221,14 +322,18 @@ export const MapDataProvider = observer(
|
||||
longitude: number
|
||||
) {
|
||||
setSightChanges((prev) => {
|
||||
let found = prev.find((sight) => sight.sight_id === sightId);
|
||||
if (found) {
|
||||
found.latitude = latitude;
|
||||
found.longitude = longitude;
|
||||
const existingIndex = prev.findIndex(
|
||||
(sight) => sight.sight_id === sightId
|
||||
);
|
||||
|
||||
return prev.map((sight) => {
|
||||
if (sight.sight_id === sightId) {
|
||||
return found;
|
||||
if (existingIndex !== -1) {
|
||||
return prev.map((sight, index) => {
|
||||
if (index === existingIndex) {
|
||||
return {
|
||||
...sight,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
return sight;
|
||||
});
|
||||
@@ -249,9 +354,7 @@ export const MapDataProvider = observer(
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log("sightChanges", sightChanges);
|
||||
}, [sightChanges]);
|
||||
useEffect(() => {}, [sightChanges]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
@@ -264,11 +367,14 @@ export const MapDataProvider = observer(
|
||||
isRouteLoading,
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
setSelectedSight,
|
||||
setScaleRange,
|
||||
setMapRotation,
|
||||
setMapCenter,
|
||||
saveChanges,
|
||||
setStationOffset,
|
||||
setStationAlign,
|
||||
setSightCoordinates,
|
||||
}),
|
||||
[
|
||||
@@ -281,6 +387,7 @@ export const MapDataProvider = observer(
|
||||
isRouteLoading,
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||
import { SCALE_FACTOR } from "./Constants";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export function RightSidebar() {
|
||||
const {
|
||||
@@ -20,19 +22,31 @@ export function RightSidebar() {
|
||||
screenCenter,
|
||||
rotateToAngle,
|
||||
setTransform,
|
||||
scale,
|
||||
setScaleAtCenter,
|
||||
} = useTransform();
|
||||
|
||||
const [minScale, setMinScale] = useState<number>(1);
|
||||
const [maxScale, setMaxScale] = useState<number>(10);
|
||||
const [maxScale, setMaxScale] = useState<number>(5);
|
||||
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
|
||||
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData) {
|
||||
setMinScale(originalRouteData.scale_min ?? 1);
|
||||
setMaxScale(originalRouteData.scale_max ?? 10);
|
||||
// Проверяем и сбрасываем минимальный масштаб если нужно
|
||||
const originalMinScale = originalRouteData.scale_min ?? 1;
|
||||
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
|
||||
|
||||
// Проверяем и сбрасываем максимальный масштаб если нужно
|
||||
const originalMaxScale = originalRouteData.scale_max ?? 5;
|
||||
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
|
||||
|
||||
setMinScale(resetMinScale);
|
||||
setMaxScale(resetMaxScale);
|
||||
setRotationDegrees(originalRouteData.rotate ?? 0);
|
||||
setLocalCenter({
|
||||
x: originalRouteData.center_latitude ?? 0,
|
||||
@@ -52,16 +66,26 @@ export function RightSidebar() {
|
||||
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
|
||||
);
|
||||
}, [rotation]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapRotation(rotationDegrees);
|
||||
}, [rotationDegrees]);
|
||||
|
||||
useEffect(() => {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const localCenter = screenToLocal(center.x, center.y);
|
||||
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
|
||||
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
|
||||
}, [position]);
|
||||
if (!isUserEditing) {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const localCenter = screenToLocal(center.x, center.y);
|
||||
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
|
||||
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
|
||||
}
|
||||
}, [
|
||||
position,
|
||||
screenCenter,
|
||||
screenToLocal,
|
||||
localToCoordinates,
|
||||
setLocalCenter,
|
||||
isUserEditing,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapCenter(localCenter.x, localCenter.y);
|
||||
@@ -77,7 +101,6 @@ export function RightSidebar() {
|
||||
}
|
||||
|
||||
if (!routeData) {
|
||||
console.error("routeData is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -104,7 +127,30 @@ export function RightSidebar() {
|
||||
label="Минимальный масштаб"
|
||||
variant="filled"
|
||||
value={minScale}
|
||||
onChange={(e) => setMinScale(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
let newMinScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
}
|
||||
|
||||
setMinScale(newMinScale);
|
||||
|
||||
if (maxScale - newMinScale < 2) {
|
||||
let newMaxScale = newMinScale + 2;
|
||||
// Сбрасываем максимальный к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
setMinScale(1); // Сбрасываем минимальный к 1
|
||||
}
|
||||
setMaxScale(newMaxScale);
|
||||
}
|
||||
|
||||
if (newMinScale > scale * SCALE_FACTOR) {
|
||||
setScaleAtCenter(newMinScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -116,7 +162,8 @@ export function RightSidebar() {
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0.1,
|
||||
min: 1,
|
||||
max: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -125,7 +172,30 @@ export function RightSidebar() {
|
||||
label="Максимальный масштаб"
|
||||
variant="filled"
|
||||
value={maxScale}
|
||||
onChange={(e) => setMaxScale(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
let newMaxScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
}
|
||||
|
||||
setMaxScale(newMaxScale);
|
||||
|
||||
if (newMaxScale - minScale < 2) {
|
||||
let newMinScale = newMaxScale - 2;
|
||||
// Сбрасываем минимальный к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
|
||||
}
|
||||
setMinScale(newMinScale);
|
||||
}
|
||||
|
||||
if (newMaxScale < scale * SCALE_FACTOR) {
|
||||
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -137,12 +207,71 @@ export function RightSidebar() {
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0.1,
|
||||
min: 3,
|
||||
max: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
value={scale * SCALE_FACTOR}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
setScaleAtCenter(newValue / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
min={minScale}
|
||||
max={maxScale}
|
||||
step={0.1}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Текущий масштаб"
|
||||
variant="filled"
|
||||
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||
onChange={(e) => {
|
||||
const newScale = Number(e.target.value);
|
||||
if (
|
||||
!isNaN(newScale) &&
|
||||
newScale >= minScale &&
|
||||
newScale <= maxScale
|
||||
) {
|
||||
setScaleAtCenter(newScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: minScale,
|
||||
max: maxScale,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Поворот (в градусах)"
|
||||
@@ -181,11 +310,13 @@ export function RightSidebar() {
|
||||
type="number"
|
||||
label="Центр карты, широта"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.x * 100000) / 100000}
|
||||
value={Math.round(localCenter.x * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
|
||||
pan({ x: Number(e.target.value), y: localCenter.y });
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -195,16 +326,21 @@ export function RightSidebar() {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
step: 0.001,
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Центр карты, высота"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.y * 100000) / 100000}
|
||||
value={Math.round(localCenter.y * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
|
||||
pan({ x: localCenter.x, y: Number(e.target.value) });
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -214,6 +350,9 @@ export function RightSidebar() {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
step: 0.001,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -221,8 +360,14 @@ export function RightSidebar() {
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => {
|
||||
saveChanges();
|
||||
onClick={async () => {
|
||||
try {
|
||||
await saveChanges();
|
||||
toast.success("Изменения сохранены");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при сохранении изменений");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить изменения
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { SightData } from "./types";
|
||||
import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js";
|
||||
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
|
||||
|
||||
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||
@@ -12,19 +12,21 @@ interface SightProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setSightCoordinates } = useMapData();
|
||||
const { setSightCoordinates, setSelectedSight } = useMapData();
|
||||
|
||||
const [position, setPosition] = useState(
|
||||
coordinatesToLocal(sight.latitude, sight.longitude)
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -37,7 +39,18 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
if (!isPointerDown) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
|
||||
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
|
||||
const cos = Math.cos(rotation);
|
||||
@@ -53,30 +66,33 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(false);
|
||||
|
||||
// Если не было перетаскивания, то это клик
|
||||
if (!isDragging) {
|
||||
setSelectedSight(sight);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||
useEffect(() => {
|
||||
if (texture === Texture.EMPTY) {
|
||||
Assets.load("/SightIcon.png").then((result) => {
|
||||
setTexture(result);
|
||||
});
|
||||
}
|
||||
}, [texture]);
|
||||
Assets.load("/sight_icon.svg").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
@@ -0,0 +1,60 @@
|
||||
import { Box, Typography, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
export function SightInfoWidget() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
|
||||
if (!selectedSight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
color: "white",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "4px",
|
||||
minWidth: 250,
|
||||
maxWidth: 400,
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
zIndex: 1000,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}>
|
||||
{selectedSight.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedSight(undefined)}
|
||||
sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}>
|
||||
{selectedSight.address}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "#999" }}>
|
||||
Город: {selectedSight.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
||||
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
|
||||
import {
|
||||
BACKGROUND_COLOR,
|
||||
PATH_COLOR,
|
||||
@@ -7,140 +11,565 @@ import {
|
||||
UP_SCALE,
|
||||
} from "./Constants";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { useCallback, useState } from "react";
|
||||
import { StationData } from "./types";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore } from "@shared";
|
||||
// --- Конец заглушек ---
|
||||
|
||||
// --- Декларации для react-pixi ---
|
||||
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
|
||||
declare const pixiContainer: any;
|
||||
declare const pixiGraphics: any;
|
||||
declare const pixiText: any;
|
||||
|
||||
// --- Типы ---
|
||||
type HorizontalAlign = "left" | "center" | "right";
|
||||
type VerticalAlign = "top" | "center" | "bottom";
|
||||
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
||||
type LabelAlign = "left" | "center" | "right";
|
||||
|
||||
// --- Утилиты ---
|
||||
|
||||
/**
|
||||
* Преобразует текстовое позиционирование в anchor координаты.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Получает координату anchor.x из типа выравнивания.
|
||||
*/
|
||||
|
||||
// --- Интерфейсы пропсов ---
|
||||
|
||||
interface StationProps {
|
||||
station: StationData;
|
||||
ruLabel: string | null;
|
||||
anchorPoint?: { x: number; y: number };
|
||||
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
|
||||
labelBlockAnchor?: TextAlign | { x: number; y: number };
|
||||
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
|
||||
labelAlign?: LabelAlign;
|
||||
/** Callback для изменения внутреннего выравнивания */
|
||||
onLabelAlignChange?: (align: LabelAlign) => void;
|
||||
/** Callback для отслеживания наведения на текст */
|
||||
onTextHover?: (isHovered: boolean) => void;
|
||||
}
|
||||
|
||||
export const Station = observer(
|
||||
({ station, ruLabel }: Readonly<StationProps>) => {
|
||||
const draw = useCallback((g: Graphics) => {
|
||||
interface LabelAlignmentControlProps {
|
||||
scale: number;
|
||||
currentAlign: LabelAlign;
|
||||
onAlignChange: (align: LabelAlign) => void;
|
||||
onPointerOver: () => void;
|
||||
onPointerOut: () => void;
|
||||
onControlPointerEnter: () => void;
|
||||
onControlPointerLeave: () => void;
|
||||
}
|
||||
|
||||
interface StationLabelProps
|
||||
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
|
||||
|
||||
const getAnchorFromOffset = (
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
): { x: number; y: number } => {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
return { x: 0.5, y: 0.5 };
|
||||
}
|
||||
|
||||
const length = Math.hypot(offsetX, offsetY);
|
||||
const nx = offsetX / length;
|
||||
const ny = offsetY / length;
|
||||
|
||||
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Панель управления выравниванием в стиле УрФУ
|
||||
// =========================================================================
|
||||
|
||||
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
scale,
|
||||
currentAlign,
|
||||
onAlignChange,
|
||||
|
||||
onControlPointerEnter,
|
||||
onControlPointerLeave,
|
||||
}) => {
|
||||
const controlHeight = 50 / scale;
|
||||
const controlWidth = 200 / scale;
|
||||
const fontSize = 18 / scale;
|
||||
const borderRadius = 8 / scale;
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const buttonWidth = controlWidth / 3;
|
||||
const strokeWidth = 2 / scale;
|
||||
|
||||
const drawBg = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
|
||||
// Основной фон с градиентом
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
controlWidth,
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
|
||||
|
||||
// Тонкая рамка
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
controlWidth,
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
|
||||
// Разделители между кнопками
|
||||
for (let i = 1; i < 3; i++) {
|
||||
const x = -controlWidth / 2 + buttonWidth * i;
|
||||
g.moveTo(x, strokeWidth);
|
||||
g.lineTo(x, controlHeight - strokeWidth);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
|
||||
);
|
||||
|
||||
const drawButtonHighlight = useCallback(
|
||||
(g: Graphics, index: number, isActive: boolean) => {
|
||||
g.clear();
|
||||
|
||||
if (isActive) {
|
||||
const x = -controlWidth / 2 + buttonWidth * index;
|
||||
g.roundRect(
|
||||
x + strokeWidth,
|
||||
strokeWidth,
|
||||
buttonWidth - strokeWidth * 2,
|
||||
controlHeight - strokeWidth * 2,
|
||||
borderRadius / 2
|
||||
);
|
||||
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||
);
|
||||
|
||||
const getTextStyle = (isActive: boolean) => ({
|
||||
fontSize,
|
||||
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
|
||||
fill: isActive ? "#ffffff" : "#cccccc",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
});
|
||||
|
||||
const alignOptions = [
|
||||
{ key: "left" as const, label: "Left" },
|
||||
{ key: "center" as const, label: "Center" },
|
||||
{ key: "right" as const, label: "Right" },
|
||||
];
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
|
||||
zIndex={999999999999999999}
|
||||
eventMode="static"
|
||||
onPointerOver={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerEnter();
|
||||
}}
|
||||
onPointerOut={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerLeave();
|
||||
}}
|
||||
onPointerDown={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Основной фон */}
|
||||
<pixiGraphics draw={drawBg} />
|
||||
|
||||
{/* Кнопки с подсветкой */}
|
||||
{alignOptions.map((option, index) => (
|
||||
<pixiContainer key={option.key}>
|
||||
{/* Подсветка активной кнопки */}
|
||||
<pixiGraphics
|
||||
draw={(g: Graphics) =>
|
||||
drawButtonHighlight(g, index, option.key === currentAlign)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Текст кнопки */}
|
||||
<pixiText
|
||||
text={option.label}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
position={{
|
||||
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
|
||||
y: controlHeight / 2,
|
||||
}}
|
||||
style={getTextStyle(option.key === currentAlign)}
|
||||
eventMode="static"
|
||||
cursor="pointer"
|
||||
onClick={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAlignChange(option.key);
|
||||
}}
|
||||
onPointerDown={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAlignChange(option.key);
|
||||
}}
|
||||
onPointerOver={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerEnter();
|
||||
}}
|
||||
/>
|
||||
</pixiContainer>
|
||||
))}
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Метка Станции (с логикой)
|
||||
// =========================================================================
|
||||
|
||||
const StationLabel = observer(
|
||||
({
|
||||
station,
|
||||
ruLabel,
|
||||
|
||||
labelAlign: labelAlignProp = "center",
|
||||
onLabelAlignChange,
|
||||
onTextHover,
|
||||
}: Readonly<StationLabelProps>) => {
|
||||
const { language } = languageStore;
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setStationOffset, setStationAlign } = useMapData();
|
||||
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isControlHovered, setIsControlHovered] = useState(false);
|
||||
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
|
||||
const [ruLabelWidth, setRuLabelWidth] = useState(0);
|
||||
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const mouseStartPos = useRef({ x: 0, y: 0 });
|
||||
const hideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const ruLabelRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text is hovered
|
||||
};
|
||||
|
||||
const handleControlPointerEnter = () => {
|
||||
// Дополнительная обработка для панели управления
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsControlHovered(true);
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text/control is hovered
|
||||
};
|
||||
|
||||
const handleControlPointerLeave = () => {
|
||||
setIsControlHovered(false);
|
||||
// Если курсор не над основным контейнером, скрываем панель через некоторое время
|
||||
if (!isHovered) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
// Увеличиваем время до скрытия панели и добавляем проверку
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
// Если курсор не над панелью управления, скрываем и её
|
||||
if (!isControlHovered) {
|
||||
setIsControlHovered(false);
|
||||
}
|
||||
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
|
||||
}, 100); // Увеличиваем время до скрытия панели
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||
}, [station.offset_x, station.offset_y, station.id]);
|
||||
|
||||
// Функция для конвертации числового align в строковый
|
||||
const convertNumericAlign = (align: number): LabelAlign => {
|
||||
switch (align) {
|
||||
case 0:
|
||||
return "left";
|
||||
case 1:
|
||||
return "center";
|
||||
case 2:
|
||||
return "right";
|
||||
default:
|
||||
return "center";
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для конвертации строкового align в числовой
|
||||
const convertStringAlign = (align: LabelAlign): number => {
|
||||
switch (align) {
|
||||
case "left":
|
||||
return 0;
|
||||
case "center":
|
||||
return 1;
|
||||
case "right":
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
|
||||
}, [station.align]);
|
||||
|
||||
if (!station) return null;
|
||||
|
||||
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const compensatedNameFontSize = (16 * 0.75) / scale;
|
||||
|
||||
// Измеряем ширину верхнего лейбла
|
||||
useEffect(() => {
|
||||
if (ruLabelRef.current && ruLabel) {
|
||||
setRuLabelWidth(ruLabelRef.current.width);
|
||||
}
|
||||
}, [ruLabel, compensatedRuFontSize]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
dragStartPos.current = { ...position };
|
||||
mouseStartPos.current = { x: e.global.x, y: e.global.y };
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isPointerDown) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.global.x - mouseStartPos.current.x;
|
||||
const dy = e.global.y - mouseStartPos.current.y;
|
||||
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
|
||||
else return;
|
||||
}
|
||||
|
||||
const dx_screen = e.global.x - mouseStartPos.current.x;
|
||||
const dy_screen = e.global.y - mouseStartPos.current.y;
|
||||
|
||||
const newPosition = {
|
||||
x: dragStartPos.current.x + dx_screen,
|
||||
y: dragStartPos.current.y + dy_screen,
|
||||
};
|
||||
|
||||
// Проверяем, изменилась ли позиция
|
||||
if (
|
||||
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||
Math.abs(newPosition.y - position.y) > 0.01
|
||||
) {
|
||||
setPosition(newPosition);
|
||||
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(false);
|
||||
setTimeout(() => setIsDragging(false), 50);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleAlignChange = async (align: LabelAlign) => {
|
||||
setCurrentLabelAlign(align);
|
||||
onLabelAlignChange?.(align);
|
||||
// Сохраняем в стор
|
||||
const numericAlign = convertStringAlign(align);
|
||||
setStationAlign(station.id, numericAlign);
|
||||
};
|
||||
|
||||
const dynamicAnchor = useMemo(
|
||||
() => getAnchorFromOffset(position.x, position.y),
|
||||
[position.x, position.y]
|
||||
);
|
||||
|
||||
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
|
||||
const getSecondLabelPosition = (): number => {
|
||||
if (!ruLabelWidth) return 0;
|
||||
|
||||
switch (currentLabelAlign) {
|
||||
case "left":
|
||||
// Позиционируем относительно левого края верхнего текста
|
||||
return -ruLabelWidth / 2;
|
||||
case "center":
|
||||
// Центрируем относительно центра верхнего текста
|
||||
return 0;
|
||||
case "right":
|
||||
// Позиционируем относительно правого края верхнего текста
|
||||
return ruLabelWidth / 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для расчета anchor нижнего лейбла
|
||||
const getSecondLabelAnchor = (): number => {
|
||||
switch (currentLabelAlign) {
|
||||
case "left":
|
||||
return 0; // anchor.x = 0 (левый край)
|
||||
case "center":
|
||||
return 0.5; // anchor.x = 0.5 (центр)
|
||||
case "right":
|
||||
return 1; // anchor.x = 1 (правый край)
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
x={coordinates.x * UP_SCALE}
|
||||
y={coordinates.y * UP_SCALE}
|
||||
rotation={-rotation}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
eventMode="static"
|
||||
interactive
|
||||
cursor={isDragging ? "grabbing" : "grab"}
|
||||
onPointerOver={handlePointerEnter}
|
||||
onPointerOut={handlePointerLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
>
|
||||
<pixiContainer
|
||||
position={{
|
||||
x:
|
||||
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||||
scale,
|
||||
y:
|
||||
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||||
scale,
|
||||
}}
|
||||
anchor={dynamicAnchor}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
>
|
||||
{ruLabel && (
|
||||
<pixiText
|
||||
ref={ruLabelRef}
|
||||
text={ruLabel}
|
||||
position={{ x: 0, y: 0 }}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
style={{
|
||||
fontSize: compensatedRuFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{station.name && language !== "ru" && ruLabel && (
|
||||
<pixiText
|
||||
text={station.name}
|
||||
position={{
|
||||
x: getSecondLabelPosition(),
|
||||
y: compensatedRuFontSize * 1.1,
|
||||
}}
|
||||
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
|
||||
style={{
|
||||
fontSize: compensatedNameFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#CCCCCC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(isHovered || isControlHovered) && !isDragging && (
|
||||
<LabelAlignmentControl
|
||||
scale={scale}
|
||||
currentAlign={currentLabelAlign}
|
||||
onAlignChange={handleAlignChange}
|
||||
onPointerOver={handlePointerEnter}
|
||||
onPointerOut={handlePointerLeave}
|
||||
onControlPointerEnter={handleControlPointerEnter}
|
||||
onControlPointerLeave={handleControlPointerLeave}
|
||||
/>
|
||||
)}
|
||||
</pixiContainer>
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Главный экспортируемый компонент: Станция
|
||||
// =========================================================================
|
||||
|
||||
export const Station = ({
|
||||
station,
|
||||
ruLabel,
|
||||
|
||||
labelAlign,
|
||||
onLabelAlignChange,
|
||||
}: Readonly<StationProps>) => {
|
||||
const [isTextHovered, setIsTextHovered] = useState(false);
|
||||
|
||||
const draw = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
const coordinates = coordinatesToLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
g.circle(
|
||||
coordinates.x * UP_SCALE,
|
||||
coordinates.y * UP_SCALE,
|
||||
STATION_RADIUS
|
||||
);
|
||||
g.fill({ color: PATH_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pixiContainer>
|
||||
<pixiGraphics draw={draw} />
|
||||
<StationLabel station={station} ruLabel={ruLabel} />
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
const radius = STATION_RADIUS;
|
||||
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||||
|
||||
export const StationLabel = observer(
|
||||
({ station, ruLabel }: Readonly<StationProps>) => {
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setStationOffset } = useMapData();
|
||||
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||
|
||||
const [position, setPosition] = useState({
|
||||
x: station.offset_x,
|
||||
y: station.offset_y,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [startMousePosition, setStartMousePosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
// Change fill color when text is hovered
|
||||
if (isTextHovered) {
|
||||
g.fill({ color: 0x00aaff }); // Highlight color when hovered
|
||||
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
|
||||
} else {
|
||||
g.fill({ color: PATH_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||
}
|
||||
},
|
||||
[station.latitude, station.longitude, isTextHovered]
|
||||
);
|
||||
|
||||
if (!station) {
|
||||
console.error("station is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(true);
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
setStartMousePosition({
|
||||
x: e.globalX,
|
||||
y: e.globalY,
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
const newPosition = {
|
||||
x: startPosition.x + dx,
|
||||
y: startPosition.y + dy,
|
||||
};
|
||||
setPosition(newPosition);
|
||||
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsDragging(false);
|
||||
e.stopPropagation();
|
||||
};
|
||||
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
eventMode="static"
|
||||
interactive
|
||||
onPointerDown={handlePointerDown}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
width={48}
|
||||
height={48}
|
||||
x={coordinates.x * UP_SCALE}
|
||||
y={coordinates.y * UP_SCALE}
|
||||
rotation={-rotation}
|
||||
>
|
||||
<pixiText
|
||||
anchor={{ x: 1, y: 0.5 }}
|
||||
text={station.name}
|
||||
position={{
|
||||
x: position.x / scale + 24,
|
||||
y: position.y / scale,
|
||||
}}
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
|
||||
{ruLabel && (
|
||||
<pixiText
|
||||
anchor={{ x: 1, y: -1 }}
|
||||
text={ruLabel}
|
||||
position={{
|
||||
x: position.x / scale + 24,
|
||||
y: position.y / scale,
|
||||
}}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
fill: "#CCCCCC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||||
<pixiGraphics draw={draw} />
|
||||
<StationLabel
|
||||
station={station}
|
||||
ruLabel={ruLabel}
|
||||
labelAlign={labelAlign}
|
||||
onLabelAlignChange={onLabelAlignChange}
|
||||
onTextHover={setIsTextHovered}
|
||||
/>
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,9 +26,12 @@ const TransformContext = createContext<{
|
||||
rotationDegrees?: number,
|
||||
scale?: number
|
||||
) => void;
|
||||
setScaleOnly: (newScale: number) => void;
|
||||
setScaleWithoutMovingCenter: (newScale: number) => void;
|
||||
setScreenCenter: React.Dispatch<
|
||||
React.SetStateAction<{ x: number; y: number } | undefined>
|
||||
>;
|
||||
setScaleAtCenter: (newScale: number) => void;
|
||||
}>({
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
@@ -41,7 +44,10 @@ const TransformContext = createContext<{
|
||||
localToScreen: () => ({ x: 0, y: 0 }),
|
||||
rotateToAngle: () => {},
|
||||
setTransform: () => {},
|
||||
setScaleOnly: () => {},
|
||||
setScaleWithoutMovingCenter: () => {},
|
||||
setScreenCenter: () => {},
|
||||
setScaleAtCenter: () => {},
|
||||
});
|
||||
|
||||
// Provider component
|
||||
@@ -136,8 +142,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
|
||||
console.log("center", center.x, center.y);
|
||||
|
||||
const newPosition = {
|
||||
x: -latitude * UP_SCALE * selectedScale,
|
||||
y: -longitude * UP_SCALE * selectedScale,
|
||||
@@ -160,6 +164,37 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
[rotation, scale, screenCenter]
|
||||
);
|
||||
|
||||
const setScaleAtCenter = useCallback(
|
||||
(newScale: number) => {
|
||||
if (scale === newScale) return;
|
||||
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
|
||||
const actualZoomFactor = newScale / scale;
|
||||
|
||||
const newPosition = {
|
||||
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
|
||||
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
|
||||
};
|
||||
|
||||
setPosition(newPosition);
|
||||
setScale(newScale);
|
||||
},
|
||||
[position, scale, screenCenter]
|
||||
);
|
||||
|
||||
const setScaleOnly = useCallback((newScale: number) => {
|
||||
// Изменяем только масштаб, не трогая позицию и поворот
|
||||
setScale(newScale);
|
||||
}, []);
|
||||
|
||||
const setScaleWithoutMovingCenter = useCallback(
|
||||
(newScale: number) => {
|
||||
setScale(newScale);
|
||||
},
|
||||
[setScale]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
position,
|
||||
@@ -173,17 +208,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
screenToLocal,
|
||||
localToScreen,
|
||||
setTransform,
|
||||
setScaleOnly,
|
||||
setScaleWithoutMovingCenter,
|
||||
setScreenCenter,
|
||||
setScaleAtCenter,
|
||||
}),
|
||||
[
|
||||
position,
|
||||
scale,
|
||||
rotation,
|
||||
screenCenter,
|
||||
setScale,
|
||||
rotateToAngle,
|
||||
screenToLocal,
|
||||
localToScreen,
|
||||
setTransform,
|
||||
setScaleOnly,
|
||||
setScaleWithoutMovingCenter,
|
||||
setScreenCenter,
|
||||
setScaleAtCenter,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { Stack, Typography, Box, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { Landmark } from "lucide-react";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
export function Widgets() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
@@ -24,6 +29,8 @@ export function Widgets() {
|
||||
Станция
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={223}
|
||||
@@ -31,12 +38,102 @@ export function Widgets() {
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Погода
|
||||
</Typography>
|
||||
{selectedSight ? (
|
||||
<Box
|
||||
sx={{ height: "100%", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* Заголовок с кнопкой закрытия */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 0.5 }}>
|
||||
<Landmark size={16} className="shrink-0" />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: "#fff", fontWeight: "bold" }}
|
||||
>
|
||||
{selectedSight.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedSight(undefined)}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
p: 0,
|
||||
minWidth: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
|
||||
}}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Описание достопримечательности */}
|
||||
{selectedSight.address && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#fff",
|
||||
mb: 1,
|
||||
opacity: 0.9,
|
||||
lineHeight: 1.3,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
}}
|
||||
>
|
||||
{selectedSight.address}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Город */}
|
||||
{selectedSight.city && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#fff",
|
||||
opacity: 0.7,
|
||||
mt: "auto",
|
||||
}}
|
||||
>
|
||||
Город: {selectedSight.city}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Landmark size={32} />
|
||||
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
|
||||
Выберите достопримечательность
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
|
||||
import { Widgets } from "./Widgets";
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import {
|
||||
Container,
|
||||
@@ -14,16 +14,19 @@ import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||
import { TransformProvider, useTransform } from "./TransformContext";
|
||||
import { InfiniteCanvas } from "./InfiniteCanvas";
|
||||
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import { Station } from "./Station";
|
||||
import { TravelPath } from "./TravelPath";
|
||||
import { LeftSidebar } from "./LeftSidebar";
|
||||
import { RightSidebar } from "./RightSidebar";
|
||||
import { Widgets } from "./Widgets";
|
||||
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Sight } from "./Sight";
|
||||
import { SightData } from "./types";
|
||||
import { Station } from "./Station";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
extend({
|
||||
Container,
|
||||
@@ -34,17 +37,31 @@ extend({
|
||||
Text,
|
||||
});
|
||||
|
||||
const Loading = () => {
|
||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||
|
||||
if (isRouteLoading || isStationLoading || isSightLoading) {
|
||||
return (
|
||||
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
export const RoutePreview = () => {
|
||||
const { routeData, stationData, sightData } = useMapData();
|
||||
return (
|
||||
<MapDataProvider>
|
||||
<TransformProvider>
|
||||
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
|
||||
<LanguageSwitcher />
|
||||
|
||||
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
|
||||
<Loading />
|
||||
<LeftSidebar />
|
||||
<Stack direction="row" flex={1} position="relative" height="100%">
|
||||
<Widgets />
|
||||
<RouteMap />
|
||||
<Widgets />
|
||||
<RightSidebar />
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -55,15 +72,27 @@ export const RoutePreview = () => {
|
||||
|
||||
export const RouteMap = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const { setPosition, screenToLocal, setTransform, screenCenter } =
|
||||
useTransform();
|
||||
const { routeData, stationData, sightData, originalRouteData } = useMapData();
|
||||
console.log(stationData);
|
||||
const { setPosition, setTransform, screenCenter } = useTransform();
|
||||
const {
|
||||
routeData,
|
||||
stationData,
|
||||
sightData,
|
||||
originalRouteData,
|
||||
originalSightData,
|
||||
} = useMapData();
|
||||
|
||||
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
||||
const [isSetup, setIsSetup] = useState(false);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData) {
|
||||
const path = originalRouteData?.path;
|
||||
@@ -131,13 +160,13 @@ export const RouteMap = observer(() => {
|
||||
]);
|
||||
|
||||
if (!routeData || !stationData || !sightData) {
|
||||
console.error("routeData, stationData or sightData is null");
|
||||
return <div>Loading...</div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||
<Application resizeTo={parentRef} background="#fff">
|
||||
<LanguageSwitcher />
|
||||
<Application resizeTo={parentRef} background="#fff" preference="webgl">
|
||||
<InfiniteCanvas>
|
||||
<TravelPath points={points} />
|
||||
{stationData[language].map((obj, index) => (
|
||||
@@ -146,20 +175,14 @@ export const RouteMap = observer(() => {
|
||||
key={obj.id}
|
||||
ruLabel={
|
||||
language === "ru"
|
||||
? stationData.en[index].name
|
||||
? stationData.ru[index].name
|
||||
: stationData.ru[index].name
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<pixiGraphics
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
const localCenter = screenToLocal(0, 0);
|
||||
g.circle(localCenter.x, localCenter.y, 10);
|
||||
g.fill("#fff");
|
||||
}}
|
||||
/>
|
||||
{originalSightData?.map((sight: SightData, index: number) => {
|
||||
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||
})}
|
||||
</InfiniteCanvas>
|
||||
</Application>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +1,72 @@
|
||||
export interface RouteData {
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
route_number: string;
|
||||
route_sys_number: string;
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
route_number: string;
|
||||
route_sys_number: string;
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
thumbnail?: string; // uuid логотипа маршрута
|
||||
}
|
||||
|
||||
export interface StationTransferData {
|
||||
bus: string;
|
||||
metro_blue: string;
|
||||
metro_green: string;
|
||||
metro_orange: string;
|
||||
metro_purple: string;
|
||||
metro_red: string;
|
||||
train: string;
|
||||
tram: string;
|
||||
trolleybus: string;
|
||||
bus: string;
|
||||
metro_blue: string;
|
||||
metro_green: string;
|
||||
metro_orange: string;
|
||||
metro_purple: string;
|
||||
metro_red: string;
|
||||
train: string;
|
||||
tram: string;
|
||||
trolleybus: string;
|
||||
}
|
||||
|
||||
export interface StationData {
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
system_name: string;
|
||||
transfers: StationTransferData;
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
system_name: string;
|
||||
transfers: StationTransferData;
|
||||
align: number;
|
||||
}
|
||||
|
||||
export interface StationPatchData {
|
||||
station_id: number;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
transfers: StationTransferData;
|
||||
station_id: number;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
align: number;
|
||||
transfers: StationTransferData;
|
||||
}
|
||||
|
||||
export interface SightPatchData {
|
||||
sight_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
sight_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface SightData {
|
||||
address: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
id: number;
|
||||
latitude: number;
|
||||
left_article: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
preview_media: number;
|
||||
thumbnail: string; // uuid
|
||||
watermark_lu: string; // uuid
|
||||
watermark_rd: string; // uuid
|
||||
}
|
||||
address: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
id: number;
|
||||
latitude: number;
|
||||
left_article: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
preview_media: number;
|
||||
thumbnail: string; // uuid
|
||||
watermark_lu: string; // uuid
|
||||
watermark_rd: string; // uuid
|
||||
}
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, sightsStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
cityStore,
|
||||
languageStore,
|
||||
sightsStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const SightListPage = observer(() => {
|
||||
const { sights, getSights, deleteListSight } = sightsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getSights();
|
||||
const fetchSights = async () => {
|
||||
setIsLoading(true);
|
||||
await getCities(language);
|
||||
await getSights();
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSights();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -22,18 +40,40 @@ export const SightListPage = observer(() => {
|
||||
field: "name",
|
||||
headerName: "Имя",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city",
|
||||
field: "city_id",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
cities[language].data.find((el) => el.id == params.value)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -58,10 +98,19 @@ export const SightListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = sights.map((sight) => ({
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return sights;
|
||||
}
|
||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||
}, [sights, selectedCityStore.selectedCityId]);
|
||||
|
||||
const rows = filteredSights.map((sight) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
city: sight.city,
|
||||
city_id: sight.city_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -76,11 +125,41 @@ export const SightListPage = observer(() => {
|
||||
path="/sight/create"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет достопримечательностей"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +177,19 @@ export const SightListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteListSight(id)));
|
||||
getSights();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,69 +1,68 @@
|
||||
import { Button, Paper, TextField } from "@mui/material";
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { snapshotStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const SnapshotCreatePage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getSnapshot, createSnapshot } = snapshotStore;
|
||||
const { createSnapshot } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getSnapshot(id as string);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="w-full h-[400px] flex justify-center items-center">
|
||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
navigate(-1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании снапшота");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, snapshotStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
|
||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const SnapshotListPage = observer(() => {
|
||||
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||
@@ -14,9 +15,15 @@ export const SnapshotListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const { language } = languageStore;
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSnapshots();
|
||||
const fetchSnapshots = async () => {
|
||||
setIsLoading(true);
|
||||
await getSnapshots();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSnapshots();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -36,6 +43,7 @@ export const SnapshotListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
width: 300,
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -81,6 +89,15 @@ export const SnapshotListPage = observer(() => {
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,12 +118,15 @@ export const SnapshotListPage = observer(() => {
|
||||
|
||||
<SnapshotRestore
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
onDelete={async () => {
|
||||
setIsLoading(true);
|
||||
if (rowId) {
|
||||
await restoreSnapshot(rowId);
|
||||
}
|
||||
setIsRestoreModalOpen(false);
|
||||
setRowId(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsRestoreModalOpen(false);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./SnapshotListPage";
|
||||
|
||||
export * from "./SnapshotCreatePage";
|
||||
|
||||
328
src/pages/Station/LinkedSights.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedSightsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
onUpdate?: () => void;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
export const LinkedSights = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedSightsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные достопримечательности
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedSightsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedSightsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
}: LinkedSightsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "station";
|
||||
const childResource = "sight";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
// Фильтруем по городу из навбара
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
sight_id: selectedItemId,
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
setLinkedItems([...linkedItems, newItem]);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking sight:", error);
|
||||
setError("Failed to link sight");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting sight:", error);
|
||||
setError("Failed to delete sight");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked sights:", error);
|
||||
setError("Failed to load linked sights");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all sights:", error);
|
||||
setError("Failed to load available sights");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>{field.label}</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{linkedItems.map((item, index) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Достопримечательности не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">
|
||||
Добавить достопримечательность
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find((item) => item.id === selectedItemId) || null
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите достопримечательность"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
{String(option.name)}
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Загрузка...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" textAlign="center" py={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedSightsContents = observer(
|
||||
LinkedSightsContentsInner
|
||||
) as typeof LinkedSightsContentsInner;
|
||||
@@ -8,35 +8,111 @@ import {
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [systemName, setSystemName] = useState("");
|
||||
const [direction, setDirection] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
createStationData,
|
||||
setCreateCommonData,
|
||||
createStation,
|
||||
setLanguageCreateStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
useEffect(() => {
|
||||
if (
|
||||
createStationData.common.latitude !== 0 ||
|
||||
createStationData.common.longitude !== 0
|
||||
) {
|
||||
setCoordinates(
|
||||
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
|
||||
);
|
||||
}
|
||||
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
|
||||
const executeCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await stationsStore.createStation(name, systemName, direction);
|
||||
toast.success("Станция успешно создана");
|
||||
await createStation();
|
||||
toast.success("Остановка успешно создана");
|
||||
navigate("/station");
|
||||
} catch (error) {
|
||||
console.error("Error creating station:", error);
|
||||
toast.error("Ошибка при создании станции");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
|
||||
const handleCreate = async () => {
|
||||
const isCityMissing = !createStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmCreate = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelCreate = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCities = async () => {
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
};
|
||||
|
||||
fetchCities();
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||
setCreateCommonData({
|
||||
city_id: selectedCityId,
|
||||
city: selectedCity.name,
|
||||
});
|
||||
}
|
||||
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
@@ -45,42 +121,123 @@ export const StationCreatePage = observer(() => {
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание станции</h1>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">Создание остановки</h1>
|
||||
</div>
|
||||
<TextField
|
||||
className="w-full"
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={createStationData[language].name || ""}
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Системное название"
|
||||
required
|
||||
value={systemName}
|
||||
onChange={(e) => setSystemName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setLanguageCreateStationData(language, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Направление</InputLabel>
|
||||
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={direction}
|
||||
label="Направление"
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
required
|
||||
labelId="direction-label"
|
||||
value={createStationData.common.direction ? "Прямой" : "Обратный"}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) =>
|
||||
setCreateCommonData({
|
||||
direction: e.target.value === "Прямой",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="forward">Прямое</MenuItem>
|
||||
<MenuItem value="backward">Обратное</MenuItem>
|
||||
<MenuItem value="Прямой">Прямой</MenuItem>
|
||||
<MenuItem value="Обратный">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Описание"
|
||||
value={createStationData.common.description || ""}
|
||||
onChange={(e) =>
|
||||
setCreateCommonData({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* <TextField
|
||||
fullWidth
|
||||
label="Адрес"
|
||||
value={createStationData[language].address || ""}
|
||||
onChange={(e) =>
|
||||
setLanguageCreateStationData(language, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/> */}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Координаты"
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const input = newValue.replace(/,/g, " ").trim();
|
||||
const [latStr, lonStr] = input.split(/\s+/);
|
||||
|
||||
const lat = parseFloat(latStr);
|
||||
const lon = parseFloat(lonStr);
|
||||
|
||||
const isValidLat = !isNaN(lat);
|
||||
const isValidLon = !isNaN(lon);
|
||||
|
||||
if (isValidLat && isValidLon) {
|
||||
setCreateCommonData({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
} else {
|
||||
setCreateCommonData({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={createStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setCreateCommonData({
|
||||
city_id: e.target.value as number,
|
||||
city: selectedCity?.name || "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !name || !systemName || !direction}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
@@ -89,6 +246,16 @@ export const StationCreatePage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmCreate,
|
||||
reset: handleCancelCreate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { toast } from "react-toastify";
|
||||
import { stationsStore, languageStore, cityStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -29,12 +31,31 @@ export const StationEditPage = observer(() => {
|
||||
setLanguageEditStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
const handleEdit = async () => {
|
||||
useEffect(() => {
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
editStationData.common.latitude !== 0 ||
|
||||
editStationData.common.longitude !== 0
|
||||
) {
|
||||
setCoordinates(
|
||||
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
|
||||
);
|
||||
}
|
||||
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
|
||||
const executeEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editStation(Number(id));
|
||||
toast.success("Станция успешно обновлена");
|
||||
toast.success("Остановка успешно обновлена");
|
||||
} catch (error) {
|
||||
console.error("Error updating station:", error);
|
||||
toast.error("Ошибка при обновлении станции");
|
||||
@@ -43,21 +64,52 @@ export const StationEditPage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
|
||||
const handleEdit = async () => {
|
||||
const isCityMissing = !editStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing =
|
||||
!editStationData.ru.name ||
|
||||
!editStationData.en.name ||
|
||||
!editStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmEdit = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelEdit = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAndSetStationData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
const stationId = Number(id);
|
||||
await getEditStation(stationId);
|
||||
await getCities(language);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
};
|
||||
|
||||
fetchAndSetStationData();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -69,6 +121,9 @@ export const StationEditPage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
@@ -101,15 +156,15 @@ export const StationEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Описание"
|
||||
value={editStationData[language].description || ""}
|
||||
value={editStationData.common.description || ""}
|
||||
onChange={(e) =>
|
||||
setLanguageEditStationData(language, {
|
||||
setEditCommonData({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{/* <TextField
|
||||
fullWidth
|
||||
label="Адрес"
|
||||
value={editStationData[language].address || ""}
|
||||
@@ -118,21 +173,38 @@ export const StationEditPage = observer(() => {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Координаты"
|
||||
value={`${editStationData.common.latitude} ${editStationData.common.longitude}`}
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const [latitude, longitude] = e.target.value.split(" ").map(Number);
|
||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const input = newValue.replace(/,/g, " ").trim();
|
||||
const [latStr, lonStr] = input.split(/\s+/);
|
||||
|
||||
const lat = parseFloat(latStr);
|
||||
const lon = parseFloat(lonStr);
|
||||
|
||||
const isValidLat = !isNaN(lat);
|
||||
const isValidLon = !isNaN(lon);
|
||||
|
||||
if (isValidLat && isValidLon) {
|
||||
setEditCommonData({
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
} else {
|
||||
setEditCommonData({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
@@ -141,7 +213,7 @@ export const StationEditPage = observer(() => {
|
||||
value={editStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities[language].find(
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setEditCommonData({
|
||||
@@ -150,7 +222,7 @@ export const StationEditPage = observer(() => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities[language].map((city) => (
|
||||
{cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -158,20 +230,38 @@ export const StationEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
fields={[{ label: "Название", data: "name" }]}
|
||||
type="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading || !editStationData[language]?.name}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmEdit,
|
||||
reset: handleCancelEdit,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
languageStore,
|
||||
stationsStore,
|
||||
selectedCityStore,
|
||||
cityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const StationListPage = observer(() => {
|
||||
const { stationLists, getStationList, deleteStation } = stationsStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getStationList();
|
||||
const fetchStations = async () => {
|
||||
setIsLoading(true);
|
||||
await cityStore.getCities(language);
|
||||
await getStationList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchStations();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -22,11 +38,33 @@ export const StationListPage = observer(() => {
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "system_name",
|
||||
headerName: "Системное название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "direction",
|
||||
@@ -53,6 +91,7 @@ export const StationListPage = observer(() => {
|
||||
width: 140,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -77,7 +116,18 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = stationLists[language].data.map((station: any) => ({
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return stationLists[language].data;
|
||||
}
|
||||
return stationLists[language].data.filter(
|
||||
(station: any) => station.city_id === selectedCityId
|
||||
);
|
||||
};
|
||||
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
system_name: station.system_name,
|
||||
@@ -88,16 +138,43 @@ export const StationListPage = observer(() => {
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Станции</h1>
|
||||
<CreateButton label="Создать станцию" path="/station/create" />
|
||||
<CreateButton label="Создать остановки" path="/station/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +192,19 @@ export const StationListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteStation(id)));
|
||||
getStationList();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
|
||||
export const StationPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
@@ -21,7 +22,7 @@ export const StationPreviewPage = observer(() => {
|
||||
}, [id, language]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
@@ -71,6 +72,17 @@ export const StationPreviewPage = observer(() => {
|
||||
<p>{stationPreview[id!]?.[language]?.data.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
fields={[
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Описание", data: "description" },
|
||||
]}
|
||||
type="show"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./StationListPage";
|
||||
export * from "./StationCreatePage";
|
||||
export * from "./StationPreviewPage";
|
||||
export * from "./StationEditPage";
|
||||
export * from "./LinkedSights";
|
||||
|
||||
@@ -10,15 +10,21 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore } from "@shared";
|
||||
import { userStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const UserEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { id } = useParams();
|
||||
const { editUserData, editUser, getUser, setEditUserData } = userStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -130,7 +136,7 @@ export const UserEditPage = observer(() => {
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Обновить"
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { userStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const UserListPage = observer(() => {
|
||||
const { users, getUsers, deleteUser } = userStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getUsers();
|
||||
const fetchUsers = async () => {
|
||||
setIsLoading(true);
|
||||
await getUsers();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -22,11 +31,33 @@ export const UserListPage = observer(() => {
|
||||
field: "name",
|
||||
headerName: "Имя",
|
||||
width: 400,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "email",
|
||||
headerName: "Email",
|
||||
width: 400,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "is_admin",
|
||||
@@ -52,6 +83,7 @@ export const UserListPage = observer(() => {
|
||||
flex: 1,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -93,11 +125,42 @@ export const UserListPage = observer(() => {
|
||||
<h1 className="text-2xl">Пользователи</h1>
|
||||
<CreateButton label="Создать пользователя" path="/user/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет пользователей"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +170,6 @@ export const UserListPage = observer(() => {
|
||||
if (rowId) {
|
||||
await deleteUser(rowId);
|
||||
}
|
||||
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
@@ -116,6 +178,19 @@ export const UserListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteUser(id)));
|
||||
getUsers();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared";
|
||||
import {
|
||||
vehicleStore,
|
||||
VEHICLE_TYPES,
|
||||
carrierStore,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -21,10 +26,11 @@ export const VehicleCreatePage = observer(() => {
|
||||
const [type, setType] = useState("");
|
||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers();
|
||||
}, []);
|
||||
carrierStore.getCarriers(language);
|
||||
}, [language]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
@@ -32,7 +38,8 @@ export const VehicleCreatePage = observer(() => {
|
||||
await vehicleStore.createVehicle(
|
||||
Number(tailNumber),
|
||||
Number(type),
|
||||
carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!,
|
||||
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
|
||||
?.full_name as string,
|
||||
carrierId!
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
@@ -88,7 +95,7 @@ export const VehicleCreatePage = observer(() => {
|
||||
required
|
||||
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||
>
|
||||
{carrierStore.carriers.data.map((carrier) => (
|
||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
|
||||
@@ -11,7 +11,12 @@ import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { carrierStore, VEHICLE_TYPES, vehicleStore } from "@shared";
|
||||
import {
|
||||
carrierStore,
|
||||
languageStore,
|
||||
VEHICLE_TYPES,
|
||||
vehicleStore,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const VehicleEditPage = observer(() => {
|
||||
@@ -25,11 +30,18 @@ export const VehicleEditPage = observer(() => {
|
||||
editVehicle,
|
||||
} = vehicleStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getVehicle(Number(id));
|
||||
await getCarriers();
|
||||
await getCarriers(language);
|
||||
|
||||
setEditVehicleData({
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
|
||||
type: vehicle[Number(id)]?.vehicle.type,
|
||||
@@ -37,7 +49,7 @@ export const VehicleEditPage = observer(() => {
|
||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
||||
});
|
||||
})();
|
||||
}, [id]);
|
||||
}, [id, language]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
@@ -108,7 +120,7 @@ export const VehicleEditPage = observer(() => {
|
||||
})
|
||||
}
|
||||
>
|
||||
{carrierStore.carriers.data.map((carrier) => (
|
||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { VEHICLE_TYPES } from "@shared";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const VehicleListPage = observer(() => {
|
||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
getVehicles();
|
||||
getCarriers();
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getVehicles();
|
||||
await getCarriers(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
@@ -25,17 +35,31 @@ export const VehicleListPage = observer(() => {
|
||||
field: "tail_number",
|
||||
headerName: "Бортовой номер",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "type",
|
||||
headerName: "Тип",
|
||||
flex: 1,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 items-center">
|
||||
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
|
||||
?.label || params.row.type}
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
VEHICLE_TYPES.find((type) => type.value === params.row.type)
|
||||
?.label || params.row.type
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -44,19 +68,41 @@ export const VehicleListPage = observer(() => {
|
||||
field: "carrier",
|
||||
headerName: "Перевозчик",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -86,7 +132,7 @@ export const VehicleListPage = observer(() => {
|
||||
tail_number: vehicle.vehicle.tail_number,
|
||||
type: vehicle.vehicle.type,
|
||||
carrier: vehicle.vehicle.carrier,
|
||||
city: carriers.data?.find(
|
||||
city: carriers[language].data?.find(
|
||||
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
||||
)?.city,
|
||||
}));
|
||||
@@ -101,11 +147,42 @@ export const VehicleListPage = observer(() => {
|
||||
path="/vehicle/create"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет транспортных средств"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -123,6 +200,19 @@ export const VehicleListPage = observer(() => {
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteVehicle(id)));
|
||||
getVehicles();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const authInstance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
|
||||
|
||||
const languageInstance = (language: Language) => {
|
||||
const instance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||
|
||||
58
src/shared/config/carrier.svg
Normal file
@@ -0,0 +1,58 @@
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="26px"
|
||||
width="26px"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 489.785 489.785"
|
||||
>
|
||||
<g id="XMLID_196_">
|
||||
<path
|
||||
id="XMLID_203_"
|
||||
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_202_"
|
||||
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_201_"
|
||||
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||
S194.096,172.676,176.693,160.576z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_200_"
|
||||
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_197_"
|
||||
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -7,23 +7,26 @@ import {
|
||||
Users,
|
||||
Earth,
|
||||
Landmark,
|
||||
BusFront,
|
||||
GitBranch,
|
||||
Car,
|
||||
// Car,
|
||||
Table,
|
||||
Split,
|
||||
Newspaper,
|
||||
// Newspaper,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
BookImage,
|
||||
// BookImage,
|
||||
} from "lucide-react";
|
||||
|
||||
import carrierIcon from "./carrier.svg";
|
||||
|
||||
export const DRAWER_WIDTH = 300;
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
isActive?: boolean;
|
||||
@@ -34,30 +37,12 @@ export const NAVIGATION_ITEMS: {
|
||||
secondary: NavigationItem[];
|
||||
} = {
|
||||
primary: [
|
||||
{
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
icon: BusFront,
|
||||
path: "/carrier",
|
||||
},
|
||||
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
@@ -70,24 +55,38 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
for_admin: true,
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
label: "Все сущности",
|
||||
label: "Справочник",
|
||||
icon: Table,
|
||||
nestedItems: [
|
||||
{
|
||||
id: "media",
|
||||
label: "Медиа",
|
||||
icon: BookImage,
|
||||
path: "/media",
|
||||
},
|
||||
{
|
||||
id: "articles",
|
||||
label: "Статьи",
|
||||
icon: Newspaper,
|
||||
path: "/article",
|
||||
},
|
||||
// {
|
||||
// id: "media",
|
||||
// label: "Медиа",
|
||||
// icon: BookImage,
|
||||
// path: "/media",
|
||||
// },
|
||||
// {
|
||||
// id: "articles",
|
||||
// label: "Статьи",
|
||||
// icon: Newspaper,
|
||||
// path: "/article",
|
||||
// },
|
||||
{
|
||||
id: "attractions",
|
||||
label: "Достопримечательности",
|
||||
@@ -106,20 +105,30 @@ export const NAVIGATION_ITEMS: {
|
||||
icon: Split,
|
||||
path: "/route",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: "vehicles",
|
||||
label: "Транспорт",
|
||||
icon: Car,
|
||||
path: "/vehicle",
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
{
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: () => <img src={carrierIcon} alt="Перевозчики"/>,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
import * as countries from "i18n-iso-countries";
|
||||
import * as ru from "i18n-iso-countries/langs/ru.json";
|
||||
import * as en from "i18n-iso-countries/langs/en.json";
|
||||
import * as zh from "i18n-iso-countries/langs/zh.json";
|
||||
|
||||
countries.registerLocale(ru);
|
||||
countries.registerLocale(en);
|
||||
countries.registerLocale(zh);
|
||||
|
||||
const generateCountriesList = (locale: string) => {
|
||||
const names = countries.getNames(locale);
|
||||
return Object.entries(names).map(([code, name]) => ({
|
||||
code: code,
|
||||
name: name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -8,8 +26,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 +37,9 @@ export const MEDIA_TYPE_VALUES = {
|
||||
watermark_rd: 4,
|
||||
panorama: 5,
|
||||
model: 6,
|
||||
video_preview: 2,
|
||||
};
|
||||
|
||||
export const RU_COUNTRIES = generateCountriesList("ru");
|
||||
export const EN_COUNTRIES = generateCountriesList("en");
|
||||
export const ZH_COUNTRIES = generateCountriesList("zh");
|
||||
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Допустимые типы и расширения файлов для медиа
|
||||
export const ALLOWED_MEDIA_TYPES = {
|
||||
image: {
|
||||
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||
mimeTypes: [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
],
|
||||
accept: "image/*",
|
||||
},
|
||||
video: {
|
||||
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||
mimeTypes: [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
],
|
||||
accept: "video/*",
|
||||
},
|
||||
model3d: {
|
||||
extensions: [".glb", ".gltf"],
|
||||
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||
accept: ".glb,.gltf",
|
||||
},
|
||||
panorama: {
|
||||
extensions: [".jpg", ".jpeg", ".png"],
|
||||
mimeTypes: ["image/jpeg", "image/png"],
|
||||
accept: "image/*",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getAllAllowedExtensions = (): string[] => {
|
||||
return [
|
||||
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||
];
|
||||
};
|
||||
|
||||
export const getAllAcceptString = (): string => {
|
||||
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||
};
|
||||
|
||||
export const validateFileExtension = (
|
||||
file: File
|
||||
): { valid: boolean; error?: string } => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||
const allowedExtensions = getAllAllowedExtensions();
|
||||
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||
", "
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const filterValidFiles = (
|
||||
files: File[]
|
||||
): { validFiles: File[]; errors: string[] } => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const validation = validateFileExtension(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(`${file.name}: ${validation.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { validFiles, errors };
|
||||
};
|
||||
1
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useSelectedCity";
|
||||
12
src/shared/hooks/useSelectedCity.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { selectedCityStore } from "@shared";
|
||||
|
||||
export const useSelectedCity = () => {
|
||||
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
|
||||
|
||||
return {
|
||||
selectedCity,
|
||||
selectedCityId,
|
||||
selectedCityName,
|
||||
hasSelectedCity: !!selectedCity,
|
||||
};
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from "./store";
|
||||
export * from "./const";
|
||||
export * from "./api";
|
||||
export * from "./modals";
|
||||
export * from "./hooks";
|
||||
|
||||
82
src/shared/lib/gltfCacheManager.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Утилита для управления кешем GLTF и blob URL
|
||||
*/
|
||||
|
||||
// Динамический импорт useGLTF для избежания проблем с SSR
|
||||
let useGLTF: any = null;
|
||||
|
||||
const initializeUseGLTF = async () => {
|
||||
if (!useGLTF) {
|
||||
try {
|
||||
const drei = await import("@react-three/drei");
|
||||
useGLTF = drei.useGLTF;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return useGLTF;
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает кеш GLTF для конкретного URL
|
||||
*/
|
||||
export const clearGLTFCacheForUrl = async (url: string) => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
if (gltf && gltf.clear) {
|
||||
gltf.clear(url);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает весь кеш GLTF
|
||||
*/
|
||||
export const clearAllGLTFCache = async () => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
if (gltf && gltf.clear) {
|
||||
gltf.clear();
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает blob URL из памяти браузера
|
||||
*/
|
||||
export const revokeBlobURL = (url: string) => {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Комплексная очистка: blob URL + кеш GLTF
|
||||
*/
|
||||
export const clearBlobAndGLTFCache = async (url: string) => {
|
||||
// Сначала отзываем blob URL
|
||||
revokeBlobURL(url);
|
||||
|
||||
// Затем очищаем кеш GLTF
|
||||
await clearGLTFCacheForUrl(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Очистка при смене медиа (для предотвращения конфликтов)
|
||||
*/
|
||||
export const clearMediaTransitionCache = async (
|
||||
previousMediaId: string | number | null,
|
||||
newMediaId: string | number | null,
|
||||
newMediaType?: number
|
||||
) => {
|
||||
console.log(newMediaId, newMediaType);
|
||||
// Если переключаемся с/на 3D модель, очищаем весь кеш
|
||||
if (newMediaType === 6 || previousMediaId) {
|
||||
await clearAllGLTFCache();
|
||||
}
|
||||
};
|
||||
@@ -1,2 +1,55 @@
|
||||
export * from "./mui/theme";
|
||||
export * from "./DecodeJWT";
|
||||
export * from "./gltfCacheManager";
|
||||
|
||||
/**
|
||||
* Генерирует название медиа по умолчанию в разных форматах
|
||||
*
|
||||
* Примеры использования:
|
||||
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
|
||||
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
|
||||
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
|
||||
*
|
||||
* @param objectName - Название объекта (достопримечательности, города и т.д.)
|
||||
* @param fileName - Название файла
|
||||
* @param mediaType - Тип медиа (число) или название статьи
|
||||
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
|
||||
* @returns Строка в нужном формате
|
||||
*/
|
||||
export const generateDefaultMediaName = (
|
||||
objectName: string,
|
||||
fileName: string,
|
||||
mediaType: number | string,
|
||||
isArticle: boolean = false
|
||||
): string => {
|
||||
// Убираем расширение из названия файла
|
||||
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
||||
|
||||
if (isArticle && typeof mediaType === "string") {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
||||
} else if (typeof mediaType === "number") {
|
||||
// Получаем название типа медиа
|
||||
const mediaTypeLabels: Record<number, string> = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
3: "Иконка",
|
||||
4: "Водяной знак",
|
||||
5: "Панорама",
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
||||
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
||||
|
||||
if (objectName && objectName.trim() !== "") {
|
||||
// Если есть название объекта: "Название объекта_название файла_тип медиа"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
} else {
|
||||
// Если нет названия объекта: "Название_название файла_тип медиа"
|
||||
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
|
||||
className="flex gap-4"
|
||||
dividers
|
||||
sx={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
@@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer(
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Тип медиа"
|
||||
@@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer(
|
||||
sx={{ width: "50%" }}
|
||||
/>
|
||||
|
||||
<Box className="flex gap-4 h-full">
|
||||
<Box className="flex gap-4 h-[40vh]">
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
@@ -142,7 +141,6 @@ export const PreviewMediaDialog = observer(
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 400,
|
||||
}}
|
||||
>
|
||||
<MediaViewer
|
||||
@@ -151,6 +149,8 @@ export const PreviewMediaDialog = observer(
|
||||
media_type: media.media_type,
|
||||
filename: media.filename,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
fullHeight
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ export const SelectArticleModal = observer(
|
||||
onSelectArticle,
|
||||
linkedArticleIds = [],
|
||||
}: SelectArticleModalProps) => {
|
||||
const { articles, getArticle, getArticleMedia } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const { articles, getArticle, getArticleMedia, getArticles } =
|
||||
articlesStore;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
|
||||
null
|
||||
@@ -54,6 +56,21 @@ export const SelectArticleModal = observer(
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await getArticles("ru");
|
||||
await getArticles("en");
|
||||
await getArticles("zh");
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArticleId) {
|
||||
handleArticleClick(selectedArticleId);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = async (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "enter") {
|
||||
@@ -273,6 +290,25 @@ export const SelectArticleModal = observer(
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
}}
|
||||
onDoubleClick={async () => {
|
||||
if (selectedArticleId) {
|
||||
const media = await authInstance.get(
|
||||
`/article/${selectedArticleId}/media`
|
||||
);
|
||||
onSelectArticle(
|
||||
selectedArticleId,
|
||||
articlesStore.articleData?.heading || "",
|
||||
articlesStore.articleData?.body || "",
|
||||
media.data || []
|
||||
);
|
||||
onClose();
|
||||
setSelectedArticleId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{articlesStore.articleData?.heading || "Название cтатьи"}
|
||||
|
||||
@@ -102,7 +102,6 @@ export const SelectMediaDialog = observer(
|
||||
filteredMedia = filteredMedia.filter(
|
||||
(mediaItem) => mediaItem.media_type === mediaType
|
||||
);
|
||||
console.log(filteredMedia);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,7 +162,13 @@ export const SelectMediaDialog = observer(
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={mediaItem.media_name} />
|
||||
<ListItemText
|
||||
primary={
|
||||
mediaItem.media_name
|
||||
? mediaItem.media_name
|
||||
: mediaItem.filename
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { MEDIA_TYPE_LABELS, MEDIA_TYPE_VALUES, editSightStore } from "@shared";
|
||||
import {
|
||||
MEDIA_TYPE_LABELS,
|
||||
MEDIA_TYPE_VALUES,
|
||||
editSightStore,
|
||||
generateDefaultMediaName,
|
||||
clearBlobAndGLTFCache,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -31,7 +37,24 @@ interface UploadMediaDialogProps {
|
||||
media_type: number;
|
||||
}) => void;
|
||||
afterUploadSight?: (id: string) => void;
|
||||
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | null;
|
||||
hardcodeType?:
|
||||
| "thumbnail"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
| "video_preview"
|
||||
| null;
|
||||
contextObjectName?: string;
|
||||
contextType?:
|
||||
| "sight"
|
||||
| "city"
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File; // <--- добавлено
|
||||
}
|
||||
|
||||
export const UploadMediaDialog = observer(
|
||||
@@ -41,6 +64,11 @@ export const UploadMediaDialog = observer(
|
||||
afterUpload,
|
||||
afterUploadSight,
|
||||
hardcodeType,
|
||||
contextObjectName,
|
||||
|
||||
isArticle,
|
||||
articleName,
|
||||
initialFile, // <--- добавлено
|
||||
}: UploadMediaDialogProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,6 +82,41 @@ export const UploadMediaDialog = observer(
|
||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
|
||||
[]
|
||||
);
|
||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||
const previousMediaUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFile) {
|
||||
// Очищаем предыдущий blob URL если он существует
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
setMediaFile(initialFile);
|
||||
setMediaFilename(initialFile.name);
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
|
||||
}
|
||||
}, [initialFile]);
|
||||
|
||||
// Очистка blob URL при размонтировании компонента
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
|
||||
|
||||
useEffect(() => {
|
||||
if (fileToUpload) {
|
||||
@@ -66,7 +129,11 @@ export const UploadMediaDialog = observer(
|
||||
setAvailableMediaTypes([6]);
|
||||
setMediaType(6);
|
||||
}
|
||||
if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
// Для изображений доступны все типы кроме видео
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
|
||||
setMediaType(1); // По умолчанию Фото
|
||||
@@ -76,14 +143,112 @@ export const UploadMediaDialog = observer(
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем название по умолчанию если есть контекст
|
||||
if (fileToUpload.name) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
articleName,
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
defaultName = generateDefaultMediaName(
|
||||
"",
|
||||
fileToUpload.name,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
setMediaName(defaultName);
|
||||
}
|
||||
}
|
||||
}, [fileToUpload]);
|
||||
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
||||
|
||||
// Обновляем название при изменении типа медиа
|
||||
useEffect(() => {
|
||||
if (mediaFilename && mediaType > 0) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
articleName,
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
defaultName = generateDefaultMediaName(
|
||||
"",
|
||||
mediaFilename,
|
||||
currentMediaType,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
setMediaName(defaultName);
|
||||
}
|
||||
}, [
|
||||
mediaType,
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
hardcodeType,
|
||||
isArticle,
|
||||
articleName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaFile) {
|
||||
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
|
||||
// Очищаем предыдущий blob URL и кеш GLTF если он существует
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
|
||||
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
|
||||
}
|
||||
}, [mediaFile]);
|
||||
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
|
||||
|
||||
// const fileFormat = useEffect(() => {
|
||||
// const handleKeyPress = (event: KeyboardEvent) => {
|
||||
@@ -120,6 +285,10 @@ export const UploadMediaDialog = observer(
|
||||
}
|
||||
}
|
||||
setSuccess(true);
|
||||
// Закрываем модальное окно после успешного сохранения
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
@@ -128,8 +297,20 @@ export const UploadMediaDialog = observer(
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Очищаем blob URL и кеш GLTF при закрытии диалога
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setMediaUrl(null);
|
||||
setMediaFile(null);
|
||||
setIsPreviewLoaded(false);
|
||||
previousMediaUrlRef.current = null; // Очищаем ref
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -141,7 +322,6 @@ export const UploadMediaDialog = observer(
|
||||
className="flex gap-4"
|
||||
dividers
|
||||
sx={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
@@ -188,7 +368,7 @@ export const UploadMediaDialog = observer(
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box className="flex gap-4 h-full">
|
||||
<Box className="flex gap-4 h-[40vh]">
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
@@ -197,28 +377,52 @@ export const UploadMediaDialog = observer(
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 400,
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* <MediaViewer
|
||||
media={{
|
||||
id: "",
|
||||
media_type: mediaType,
|
||||
filename: mediaFilename,
|
||||
}}
|
||||
/> */}
|
||||
{!isPreviewLoaded && mediaUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{mediaType == 2 && mediaUrl && (
|
||||
<video
|
||||
src={mediaUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
controls
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
onLoadedData={() => setIsPreviewLoaded(true)}
|
||||
onError={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{mediaType === 6 && mediaUrl && (
|
||||
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
|
||||
<ModelViewer3D
|
||||
fileUrl={mediaUrl}
|
||||
height="100%"
|
||||
onLoad={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
|
||||
<img
|
||||
src={mediaUrl ?? ""}
|
||||
alt="Uploaded media"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onLoad={() => setIsPreviewLoaded(true)}
|
||||
onError={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -226,18 +430,31 @@ export const UploadMediaDialog = observer(
|
||||
<Box className="flex flex-col gap-2 self-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
sx={{
|
||||
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
|
||||
"&:hover": {
|
||||
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
|
||||
},
|
||||
}}
|
||||
startIcon={
|
||||
isLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (!mediaName && !mediaFilename)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(!mediaName && !mediaFilename) ||
|
||||
!isPreviewLoaded
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
{isLoading
|
||||
? "Сохранение..."
|
||||
: !isPreviewLoaded
|
||||
? "Загрузка превью..."
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { authInstance, editSightStore, Language, languageStore } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
editSightStore,
|
||||
Language,
|
||||
languageStore,
|
||||
languageInstance,
|
||||
} from "@shared";
|
||||
import { computed, makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Article = {
|
||||
@@ -6,6 +12,18 @@ export type Article = {
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
ru?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
en?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
zh?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Media = {
|
||||
@@ -91,7 +109,8 @@ class ArticlesStore {
|
||||
|
||||
getArticles = async (language: Language) => {
|
||||
this.articleLoading = true;
|
||||
const response = await authInstance.get("/article");
|
||||
|
||||
const response = await languageInstance(language).get("/article");
|
||||
|
||||
runInAction(() => {
|
||||
this.articles[language] = response.data;
|
||||
@@ -99,13 +118,27 @@ class ArticlesStore {
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
getArticle = async (id: number) => {
|
||||
getArticle = async (id: number, language?: Language) => {
|
||||
this.articleLoading = true;
|
||||
const response = await authInstance.get(`/article/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
let response: any;
|
||||
if (language) {
|
||||
response = await languageInstance(language).get(`/article/${id}`);
|
||||
runInAction(() => {
|
||||
if (!this.articleData) {
|
||||
this.articleData = { id, heading: "", body: "", service_name: "" };
|
||||
}
|
||||
this.articleData[language] = {
|
||||
heading: response.data.heading,
|
||||
body: response.data.body,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
response = await authInstance.get(`/article/${id}`);
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
}
|
||||
return response;
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
@@ -137,6 +170,20 @@ class ArticlesStore {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
deleteArticles = async (ids: number[]) => {
|
||||
for (const id of ids) {
|
||||
await authInstance.delete(`/article/${id}`);
|
||||
}
|
||||
|
||||
for (const id of ["ru", "en", "zh"] as Language[]) {
|
||||
runInAction(() => {
|
||||
this.articleList[id].data = this.articleList[id].data.filter(
|
||||
(article) => !ids.includes(article.id)
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const articlesStore = new ArticlesStore();
|
||||
|
||||
@@ -55,7 +55,11 @@ class AuthStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.setAuthToken(data.token);
|
||||
this.payload = response.data;
|
||||
this.payload = {
|
||||
...response.data.user,
|
||||
// @ts-ignore
|
||||
user_id: response.data.user.id,
|
||||
};
|
||||
this.error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { authInstance } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
cityStore,
|
||||
languageStore,
|
||||
languageInstance,
|
||||
Language,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Carrier = {
|
||||
@@ -9,22 +15,45 @@ export type Carrier = {
|
||||
city: string;
|
||||
city_id: number;
|
||||
logo: string;
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
// main_color: string;
|
||||
// left_color: string;
|
||||
// right_color: string;
|
||||
};
|
||||
|
||||
type Carriers = {
|
||||
type CarrierData = {
|
||||
data: Carrier[];
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
type CashedCarrier = Record<number, Carrier>;
|
||||
type Carriers = {
|
||||
ru: CarrierData;
|
||||
en: CarrierData;
|
||||
zh: CarrierData;
|
||||
};
|
||||
|
||||
type CashedCarrier = Record<
|
||||
number,
|
||||
{
|
||||
ru: Carrier | null;
|
||||
en: Carrier | null;
|
||||
zh: Carrier | null;
|
||||
}
|
||||
>;
|
||||
|
||||
class CarrierStore {
|
||||
carriers: Carriers = {
|
||||
data: [],
|
||||
loaded: false,
|
||||
ru: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
};
|
||||
carrier: CashedCarrier = {};
|
||||
|
||||
@@ -32,14 +61,14 @@ class CarrierStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
getCarriers = async () => {
|
||||
if (this.carriers.loaded) return;
|
||||
getCarriers = async (language: Language) => {
|
||||
if (this.carriers[language as keyof Carriers].loaded) return;
|
||||
|
||||
const response = await authInstance.get("/carrier");
|
||||
const response = await languageInstance(language).get("/carrier");
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers.data = response.data;
|
||||
this.carriers.loaded = true;
|
||||
this.carriers[language as keyof Carriers].data = response.data;
|
||||
this.carriers[language as keyof Carriers].loaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -47,113 +76,231 @@ class CarrierStore {
|
||||
await authInstance.delete(`/carrier/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers.data = this.carriers.data.filter(
|
||||
(carrier) => carrier.id !== id
|
||||
);
|
||||
for (const language of ["ru", "en", "zh"] as const) {
|
||||
this.carriers[language].data = this.carriers[language].data.filter(
|
||||
(carrier: Carrier) => carrier.id !== id
|
||||
);
|
||||
}
|
||||
delete this.carrier[id];
|
||||
});
|
||||
};
|
||||
|
||||
getCarrier = async (id: number) => {
|
||||
if (this.carrier[id]) return;
|
||||
const response = await authInstance.get(`/carrier/${id}`);
|
||||
if (this.carrier[id]?.ru && this.carrier[id]?.en && this.carrier[id]?.zh)
|
||||
return;
|
||||
|
||||
const ruResponse = await languageInstance("ru").get(`/carrier/${id}`);
|
||||
const enResponse = await languageInstance("en").get(`/carrier/${id}`);
|
||||
const zhResponse = await languageInstance("zh").get(`/carrier/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
if (!this.carrier[id]) {
|
||||
this.carrier[id] = {
|
||||
id: 0,
|
||||
short_name: "",
|
||||
full_name: "",
|
||||
slogan: "",
|
||||
city: "",
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: null,
|
||||
en: null,
|
||||
zh: null,
|
||||
};
|
||||
}
|
||||
this.carrier[id] = response.data;
|
||||
this.carrier[id].ru = ruResponse.data;
|
||||
this.carrier[id].en = enResponse.data;
|
||||
this.carrier[id].zh = zhResponse.data;
|
||||
});
|
||||
return response.data;
|
||||
return this.carrier[id];
|
||||
};
|
||||
|
||||
createCarrier = async (
|
||||
createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
|
||||
setCreateCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
city: string,
|
||||
cityId: number,
|
||||
main_color: string,
|
||||
left_color: string,
|
||||
right_color: string,
|
||||
slogan: string,
|
||||
logoId: string
|
||||
logoId: string,
|
||||
language: Language
|
||||
) => {
|
||||
const response = await authInstance.post("/carrier", {
|
||||
this.createCarrierData.city_id = cityId;
|
||||
this.createCarrierData.logo = logoId;
|
||||
this.createCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
city,
|
||||
city_id: cityId,
|
||||
main_color,
|
||||
left_color,
|
||||
right_color,
|
||||
slogan,
|
||||
logo: logoId,
|
||||
});
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
|
||||
createCarrier = async () => {
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.createCarrierData.city_id
|
||||
)?.name || "";
|
||||
|
||||
const payload = {
|
||||
full_name: this.createCarrierData[language].full_name,
|
||||
short_name: this.createCarrierData[language].short_name,
|
||||
city: cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
slogan: this.createCarrierData[language].slogan,
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(language).post("/carrier", payload);
|
||||
|
||||
const carrierId = response.data.id;
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers.data.push(response.data);
|
||||
this.carriers[language].data.push(response.data);
|
||||
});
|
||||
|
||||
// Create translations for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||
const patchPayload = {
|
||||
// @ts-ignore
|
||||
full_name: this.createCarrierData[lang as any].full_name as string,
|
||||
// @ts-ignore
|
||||
short_name: this.createCarrierData[lang as any].short_name as string,
|
||||
city: cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
// @ts-ignore
|
||||
slogan: this.createCarrierData[lang as any].slogan as string,
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(lang as Language).patch(
|
||||
`/carrier/${carrierId}`,
|
||||
patchPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers[lang as keyof Carriers].data.push(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
this.createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
editCarrierData = {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
city: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
city_id: 0,
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
slogan: "",
|
||||
logo: "",
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
|
||||
setEditCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
city: string,
|
||||
cityId: number,
|
||||
main_color: string,
|
||||
left_color: string,
|
||||
right_color: string,
|
||||
// main_color: string,
|
||||
// left_color: string,
|
||||
// right_color: string,
|
||||
slogan: string,
|
||||
logoId: string
|
||||
logoId: string,
|
||||
language: Language
|
||||
) => {
|
||||
this.editCarrierData = {
|
||||
this.editCarrierData.city_id = cityId;
|
||||
this.editCarrierData.logo = logoId;
|
||||
this.editCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
city,
|
||||
city_id: cityId,
|
||||
main_color: main_color,
|
||||
left_color: left_color,
|
||||
right_color: right_color,
|
||||
// main_color: main_color,
|
||||
// left_color: left_color,
|
||||
// right_color: right_color,
|
||||
slogan: slogan,
|
||||
logo: logoId,
|
||||
};
|
||||
};
|
||||
|
||||
editCarrier = async (id: number) => {
|
||||
const response = await authInstance.patch(
|
||||
`/carrier/${id}`,
|
||||
this.editCarrierData
|
||||
);
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.editCarrierData.city_id
|
||||
)?.name || "";
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers.data = this.carriers.data.map((carrier) =>
|
||||
carrier.id === id ? { ...carrier, ...response.data } : carrier
|
||||
);
|
||||
for (const lang of ["ru", "en", "zh"] as const) {
|
||||
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
|
||||
...this.editCarrierData[lang],
|
||||
city: cityName,
|
||||
city_id: this.editCarrierData.city_id,
|
||||
...(this.editCarrierData.logo
|
||||
? { logo: this.editCarrierData.logo }
|
||||
: {}),
|
||||
});
|
||||
|
||||
this.carrier[id] = response.data;
|
||||
});
|
||||
runInAction(() => {
|
||||
if (this.carrier[id]) {
|
||||
this.carrier[id][lang] = response.data;
|
||||
}
|
||||
|
||||
this.carriers[lang].data = this.carriers[lang].data.map(
|
||||
(carrier: Carrier) =>
|
||||
carrier.id === id ? { ...carrier, ...response.data } : carrier
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Language,
|
||||
languageStore,
|
||||
countryStore,
|
||||
CashedCountries,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@@ -16,9 +17,18 @@ export type City = {
|
||||
};
|
||||
|
||||
export type CashedCities = {
|
||||
ru: City[];
|
||||
en: City[];
|
||||
zh: City[];
|
||||
ru: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
};
|
||||
en: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
};
|
||||
zh: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CashedCity = {
|
||||
@@ -29,9 +39,18 @@ export type CashedCity = {
|
||||
|
||||
class CityStore {
|
||||
cities: CashedCities = {
|
||||
ru: [],
|
||||
en: [],
|
||||
zh: [],
|
||||
ru: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
en: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
zh: {
|
||||
data: [],
|
||||
loaded: false,
|
||||
},
|
||||
};
|
||||
|
||||
city: Record<string, CashedCity> = {};
|
||||
@@ -40,25 +59,37 @@ class CityStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
ruCities: City[] = [];
|
||||
ruCities: {
|
||||
data: City[];
|
||||
loaded: boolean;
|
||||
} = {
|
||||
data: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
getRuCities = async () => {
|
||||
if (this.ruCities.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await languageInstance("ru").get(`/city`);
|
||||
|
||||
runInAction(() => {
|
||||
this.ruCities = response.data;
|
||||
this.ruCities.data = response.data;
|
||||
this.ruCities.loaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
getCities = async (language: keyof CashedCities) => {
|
||||
if (this.cities[language] && this.cities[language].length > 0) {
|
||||
if (this.cities[language].loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/city`);
|
||||
const response = await languageInstance(language).get(`/city`);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language] = response.data;
|
||||
this.cities[language].data = response.data;
|
||||
this.cities[language].loaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -67,7 +98,7 @@ class CityStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authInstance.get(`/city/${code}`);
|
||||
const response = await languageInstance(language).get(`/city/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
if (!this.city[code]) {
|
||||
@@ -83,19 +114,22 @@ class CityStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
deleteCity = async (code: string, language: keyof CashedCities) => {
|
||||
deleteCity = async (code: string) => {
|
||||
await authInstance.delete(`/city/${code}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language] = this.cities[language].filter(
|
||||
(city) => city.country_code !== code
|
||||
);
|
||||
this.city[code][language] = null;
|
||||
for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) {
|
||||
this.cities[secondaryLanguage].data = this.cities[
|
||||
secondaryLanguage
|
||||
].data.filter((city) => city.id !== Number(code));
|
||||
if (this.city[code]) {
|
||||
this.city[code][secondaryLanguage] = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
createCityData = {
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: {
|
||||
@@ -111,14 +145,12 @@ class CityStore {
|
||||
|
||||
setCreateCityData = (
|
||||
name: string,
|
||||
country: string,
|
||||
country_code: string,
|
||||
arms: string,
|
||||
language: keyof CashedCities
|
||||
) => {
|
||||
this.createCityData = {
|
||||
...this.createCityData,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
[language]: {
|
||||
@@ -127,73 +159,91 @@ class CityStore {
|
||||
};
|
||||
};
|
||||
|
||||
createCity = async () => {
|
||||
const { language } = languageStore;
|
||||
const { country, country_code, arms } = this.createCityData;
|
||||
const { name } = this.createCityData[language as keyof CashedCities];
|
||||
async createCity() {
|
||||
const language = languageStore.language as Language;
|
||||
const { country_code, arms } = this.createCityData;
|
||||
const { name } = this.createCityData[language];
|
||||
|
||||
if (name && country && country_code && arms) {
|
||||
const cityResponse = await languageInstance(language as Language).post(
|
||||
if (!name || !country_code) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create city in primary language
|
||||
const cityPayload = {
|
||||
name,
|
||||
country:
|
||||
countryStore.countries[language as keyof CashedCountries]?.data.find(
|
||||
(c) => c.code === country_code
|
||||
)?.name || "",
|
||||
country_code,
|
||||
...(arms ? { arms } : {}),
|
||||
};
|
||||
|
||||
const cityResponse = await languageInstance(language).post(
|
||||
"/city",
|
||||
{
|
||||
name: name,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
}
|
||||
cityPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[language as keyof CashedCities] = [
|
||||
...this.cities[language as keyof CashedCities],
|
||||
cityResponse.data,
|
||||
];
|
||||
});
|
||||
const cityId = cityResponse.data.id;
|
||||
|
||||
for (const secondaryLanguage of ["ru", "en", "zh"].filter(
|
||||
// Create/update other language versions
|
||||
for (const secondaryLanguage of (["ru", "en", "zh"] as Language[]).filter(
|
||||
(l) => l !== language
|
||||
)) {
|
||||
const { name } =
|
||||
this.createCityData[secondaryLanguage as keyof CashedCities];
|
||||
const { name: secondaryName } = this.createCityData[secondaryLanguage];
|
||||
|
||||
const patchResponse = await languageInstance(
|
||||
secondaryLanguage as Language
|
||||
).patch(`/city/${cityResponse.data.id}`, {
|
||||
name: name,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
});
|
||||
// Get country name in secondary language
|
||||
const countryName =
|
||||
countryStore.countries[secondaryLanguage]?.data.find(
|
||||
(c) => c.code === country_code
|
||||
)?.name || "";
|
||||
|
||||
const patchPayload = {
|
||||
name: secondaryName || "",
|
||||
country: countryName,
|
||||
country_code: country_code || "",
|
||||
...(arms ? { arms } : {}),
|
||||
};
|
||||
|
||||
const patchResponse = await languageInstance(secondaryLanguage).patch(
|
||||
`/city/${cityId}`,
|
||||
patchPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.cities[secondaryLanguage as keyof CashedCities] = [
|
||||
...this.cities[secondaryLanguage as keyof CashedCities],
|
||||
this.cities[secondaryLanguage].data = [
|
||||
...this.cities[secondaryLanguage].data,
|
||||
patchResponse.data,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.createCityData = {
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
// Update primary language data
|
||||
runInAction(() => {
|
||||
this.cities[language].data = [
|
||||
...this.cities[language].data,
|
||||
cityResponse.data,
|
||||
];
|
||||
});
|
||||
|
||||
// Reset form data
|
||||
runInAction(() => {
|
||||
this.createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: { name: "" },
|
||||
en: { name: "" },
|
||||
zh: { name: "" },
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating city:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
editCityData = {
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
ru: {
|
||||
@@ -209,14 +259,12 @@ class CityStore {
|
||||
|
||||
setEditCityData = (
|
||||
name: string,
|
||||
country: string,
|
||||
country_code: string,
|
||||
arms: string,
|
||||
language: keyof CashedCities
|
||||
) => {
|
||||
this.editCityData = {
|
||||
...this.editCityData,
|
||||
country: country,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
|
||||
@@ -232,45 +280,43 @@ class CityStore {
|
||||
const { name } = this.editCityData[language as keyof CashedCities];
|
||||
const { countries } = countryStore;
|
||||
|
||||
const country = countries[language as keyof CashedCities].find(
|
||||
const country = countries[language as keyof CashedCities].data.find(
|
||||
(country) => country.code === country_code
|
||||
);
|
||||
|
||||
if (name) {
|
||||
await languageInstance(language as Language).patch(`/city/${code}`, {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
});
|
||||
await languageInstance(language as Language).patch(`/city/${code}`, {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
if (this.city[code]) {
|
||||
this.city[code][language as keyof CashedCities] = {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
};
|
||||
}
|
||||
runInAction(() => {
|
||||
if (this.city[code]) {
|
||||
this.city[code][language as keyof CashedCities] = {
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.cities[language as keyof CashedCities]) {
|
||||
this.cities[language as keyof CashedCities] = this.cities[
|
||||
language as keyof CashedCities
|
||||
].map((city) =>
|
||||
city.id === Number(code)
|
||||
? {
|
||||
id: city.id,
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
}
|
||||
: city
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.cities[language as keyof CashedCities]) {
|
||||
this.cities[language as keyof CashedCities].data = this.cities[
|
||||
language as keyof CashedCities
|
||||
].data.map((city) =>
|
||||
city.id === Number(code)
|
||||
? {
|
||||
id: city.id,
|
||||
name,
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
}
|
||||
: city
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||